diff --git a/README.md b/README.md index 8b9b410..f2ec519 100644 --- a/README.md +++ b/README.md @@ -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`. +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`. ## 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. - 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. -- 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. -- 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. ## Bill reminders diff --git a/app/BudgetService.php b/app/BudgetService.php index a957744..badafc9 100644 --- a/app/BudgetService.php +++ b/app/BudgetService.php @@ -107,7 +107,9 @@ final class BudgetService public function addIncome(string $month, string $label, float $amount, string $receivedOn, string $notes): int { + $month = Support::month($month); $this->ensureMonth($month); + $this->assertDateInMonth($receivedOn, $month, 'Income date'); $amount = max(0, $amount); if ($amount <= 0 || trim($label) === '') { 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 { $this->pdo->beginTransaction(); @@ -196,6 +250,31 @@ final class BudgetService 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( string $month, ?int $categoryId, @@ -212,6 +291,9 @@ final class BudgetService if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) { $recurrence = 'none'; } + if ($dueDate !== null) { + $this->assertDateInMonth($dueDate, Support::month($month), 'Due date'); + } $series = $recurrence === 'none' ? null : bin2hex(random_bytes(12)); $statement = $this->pdo->prepare( '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]); } + 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 { $statement = $this->pdo->prepare( @@ -269,7 +401,9 @@ final class BudgetService string $date, string $notes ): int { + $month = Support::month($month); $this->ensureMonth($month); + $this->assertDateInMonth($date, $month, 'Expense date'); if (trim($merchant) === '' || $amount <= 0) { 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->execute(['id' => $budgetItemId, 'month' => $month]); $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; } } @@ -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 { $this->pdo->beginTransaction(); @@ -351,8 +559,16 @@ final class BudgetService 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); if ($amount <= 0) { throw new RuntimeException('Transfer amount must be greater than zero.'); @@ -361,25 +577,53 @@ final class BudgetService if ($amount > $this->deadBalance() + 0.0001) { 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; $kind = 'restore'; + $notes = trim($notes) ?: 'Restored to ' . $destinationLabel; } else { + $available = (float) $this->summary($month)['remaining']; + if ($amount > max(0, $available) + 0.0001) { + throw new RuntimeException('That deposit is larger than this month’s spendable balance.'); + } $kind = 'manual_deposit'; + $sourceType = 'manual'; + $sourceId = null; + $notes = trim($notes) ?: 'Manual dormant deposit'; } - return $this->insertDeadEntry($month, $kind, $amount, 'manual', null, trim($notes) ?: ucfirst(str_replace('_', ' ', $kind))); + return $this->insertDeadEntry($month, $kind, $amount, $sourceType, $sourceId, $notes); } public function deadEntries(?string $month = null, int $limit = 100): array { if ($month) { $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]); return $statement->fetchAll(); } 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(); } @@ -390,6 +634,8 @@ final class BudgetService 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) === '') { throw new RuntimeException('Calendar event title is required.'); } @@ -437,30 +683,39 @@ final class BudgetService if (!$plan) { return [ '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, - '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); $expenses = $this->sum('transactions', '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); - $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; + $usableBeforeExpenses = $gross - $deadMovement; return [ 'month' => $month, 'starting_income' => (float) $plan['starting_income'], 'rollover_in' => (float) $plan['rollover_in'], 'additional_income' => $income, + 'fresh_income' => $freshIncome, 'gross_resources' => $gross, 'expenses' => $expenses, 'dead_movement' => $deadMovement, + 'dead_deposits' => $deadDeposits, + 'dead_restorations' => $deadRestorations, 'remaining' => $remaining, 'planned' => $planned, 'paid_to_items' => $this->sumWhere('transactions', 'amount', $month, 'budget_item_id IS NOT NULL'), - 'unallocated' => $gross - $planned - max(0, $deadMovement), - 'savings_rate' => $gross > 0 ? max(0, $deadMovement) / $gross * 100 : 0.0, + 'unallocated' => $usableBeforeExpenses - $planned, + '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 { $totals = [ + 'fresh_income' => 0.0, + 'rollover_in' => 0.0, 'gross_resources' => 0.0, 'expenses' => 0.0, 'dead_movement' => 0.0, + 'dead_deposits' => 0.0, + 'dead_restorations' => 0.0, 'remaining' => 0.0, 'planned' => 0.0, ]; @@ -499,18 +758,78 @@ final class BudgetService public function categoryStats(string $month): array { $statement = $this->pdo->prepare( - 'SELECT COALESCE(c.name, "Uncategorized") AS category_name, - COALESCE(c.color, "#718096") AS color, - COALESCE(c.monthly_limit, 0) AS monthly_limit, - COALESCE(SUM(t.amount), 0) AS spent - FROM transactions t - LEFT JOIN categories c ON c.id = t.category_id - WHERE t.month = :month + 'SELECT c.id, c.name AS category_name, c.color, c.monthly_limit, + COALESCE(SUM(t.amount), 0) AS spent, + COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :item_month AND bi.category_id = c.id), 0) AS planned + FROM categories c + LEFT JOIN transactions t ON t.category_id = c.id AND t.month = :transaction_month + WHERE c.active = 1 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' ); - $statement->execute(['month' => $month]); - return $statement->fetchAll(); + $statement->execute([ + '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 @@ -585,19 +904,45 @@ final class BudgetService 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 { $targetDate = new DateTimeImmutable($month . '-01'); - $sources = [ - ['month' => $targetDate->modify('-1 month')->format('Y-m'), 'recurrence' => 'monthly'], - ['month' => $targetDate->modify('-1 year')->format('Y-m'), 'recurrence' => 'yearly'], + $targetMonthNumber = $targetDate->format('m'); + $statements = [ + [ + '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) { - $statement = $this->pdo->prepare( - 'SELECT * FROM budget_items WHERE month = :month AND recurrence = :recurrence AND series_key IS NOT NULL' - ); - $statement->execute($source); + foreach ($statements as $source) { + $statement = $this->pdo->prepare($source['sql']); + $statement->execute($source['parameters']); + $seen = []; 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->execute(['month' => $month, 'series_key' => $item['series_key']]); if ((int) $exists->fetchColumn() > 0) { diff --git a/app/Database.php b/app/Database.php index 9ad4289..7d3468e 100644 --- a/app/Database.php +++ b/app/Database.php @@ -18,6 +18,11 @@ final class Database { $this->configPath = $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'); if ($driver === 'mysql') { diff --git a/public/assets/app.css b/public/assets/app.css index 4e3df07..bc5c698 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -1,518 +1,757 @@ :root { - --bg: #080a12; - --bg-soft: #0e1120; - --surface: rgba(19, 22, 39, .86); - --surface-solid: #131627; - --surface-2: #191d32; - --border: rgba(255, 255, 255, .09); - --border-strong: rgba(255, 255, 255, .16); - --text: #f4f5fb; - --muted: #959bb2; - --muted-2: #68708b; + --bg: #090b12; + --bg-elevated: #0e111a; + --sidebar: rgba(10, 12, 20, .96); + --surface: rgba(18, 22, 33, .88); + --surface-solid: #121621; + --surface-2: #191e2c; + --surface-3: #202637; + --border: rgba(255, 255, 255, .075); + --border-strong: rgba(255, 255, 255, .14); + --text: #f6f7fb; + --muted: #949bad; + --muted-2: #697187; --purple: #8b5cf6; - --purple-bright: #a978ff; - --blue: #2ea9ff; - --pink: #ff4fa3; - --green: #43f5a4; - --red: #ff5364; - --orange: #ff9f43; - --shadow: 0 24px 70px rgba(0, 0, 0, .28); - --radius: 20px; - --sidebar-width: 248px; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --purple-light: #a98bff; + --blue: #49a8ff; + --pink: #ff5b9d; + --green: #49e8a4; + --red: #ff6473; + --orange: #ffad5c; + --shadow: 0 22px 60px rgba(0, 0, 0, .22); + --radius: 18px; + --sidebar-width: 264px; color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } [data-theme="light"] { - --bg: #eef0f8; - --bg-soft: #e6e9f3; - --surface: rgba(255, 255, 255, .9); + --bg: #f2f4f8; + --bg-elevated: #e9edf4; + --sidebar: rgba(250, 251, 253, .97); + --surface: rgba(255, 255, 255, .92); --surface-solid: #fff; - --surface-2: #f5f6fb; - --border: rgba(30, 35, 60, .1); - --border-strong: rgba(30, 35, 60, .18); - --text: #181b2d; - --muted: #697087; - --muted-2: #8b91a3; - --shadow: 0 20px 50px rgba(41, 45, 75, .11); + --surface-2: #f4f6fa; + --surface-3: #e9edf5; + --border: rgba(23, 31, 51, .09); + --border-strong: rgba(23, 31, 51, .16); + --text: #172033; + --muted: #687187; + --muted-2: #8a92a3; + --shadow: 0 18px 45px rgba(42, 50, 73, .09); color-scheme: light; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { - margin: 0; min-width: 320px; + margin: 0; color: var(--text); background: - radial-gradient(circle at 75% -10%, rgba(139, 92, 246, .12), transparent 32rem), - radial-gradient(circle at 15% 95%, rgba(46, 169, 255, .08), transparent 30rem), + radial-gradient(circle at 77% -8%, rgba(139, 92, 246, .09), transparent 30rem), + radial-gradient(circle at 18% 92%, rgba(73, 168, 255, .055), transparent 30rem), var(--bg); font-size: 14px; line-height: 1.5; } -button, input, select, textarea { font: inherit; } +body, button, input, select, textarea { font-family: inherit; } +button, input, select, textarea { font-size: inherit; } button, a { -webkit-tap-highlight-color: transparent; } -a { color: inherit; text-decoration: none; } button { color: inherit; } -h1, h2, h3, p { margin-top: 0; } -h1 { margin-bottom: 0; font-size: clamp(1.35rem, 2.2vw, 1.8rem); line-height: 1.15; letter-spacing: -.035em; } -h2 { margin-bottom: 0; font-size: 1.05rem; letter-spacing: -.02em; } +a { color: inherit; text-decoration: none; } +h1, h2, h3, p, dl, dd { margin-top: 0; } +h1 { margin-bottom: 0; font-size: clamp(1.35rem, 2vw, 1.75rem); line-height: 1.15; letter-spacing: -.035em; } +h2 { margin-bottom: 0; font-size: 1.05rem; line-height: 1.25; letter-spacing: -.02em; } small { color: var(--muted); } -code { color: var(--green); background: rgba(67, 245, 164, .08); padding: .15rem .35rem; border-radius: 5px; } -.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } +code { + padding: .15rem .38rem; + border: 1px solid rgba(73, 232, 164, .12); + border-radius: 6px; + color: var(--green); + background: rgba(73, 232, 164, .07); +} +.icon { display: block; width: 18px; height: 18px; flex: 0 0 18px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .muted { color: var(--muted); } .green { color: var(--green) !important; } .pink { color: var(--pink) !important; } .red { color: var(--red) !important; } .orange { color: var(--orange) !important; } .eyebrow { - margin-bottom: .35rem; + margin-bottom: .38rem; color: var(--muted); - font-size: .69rem; + font-size: .66rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.heading-note { margin: .35rem 0 0; color: var(--muted); font-size: .7rem; } .app-shell { min-height: 100vh; } .sidebar { position: fixed; inset: 0 auto 0 0; - z-index: 40; + z-index: 50; display: flex; width: var(--sidebar-width); flex-direction: column; - padding: 24px 16px; + padding: 24px 16px 18px; border-right: 1px solid var(--border); - background: rgba(9, 11, 20, .88); - backdrop-filter: blur(24px); + background: var(--sidebar); + backdrop-filter: blur(22px); } -[data-theme="light"] .sidebar { background: rgba(247, 248, 252, .9); } -.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 26px; } +.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 28px; } .brand-mark { + position: relative; display: grid; - width: 39px; - height: 39px; - flex: 0 0 39px; + width: 40px; + height: 40px; + flex: 0 0 40px; place-items: center; - border: 1px solid rgba(169, 120, 255, .55); + overflow: hidden; + border: 1px solid rgba(169, 139, 255, .42); border-radius: 12px; color: #fff; - background: linear-gradient(145deg, var(--purple), #5a35ca); - box-shadow: 0 0 25px rgba(139, 92, 246, .32); - font-size: .72rem; - font-weight: 900; - letter-spacing: -.04em; + background: #17142a; + box-shadow: inset 0 0 18px rgba(139, 92, 246, .17), 0 9px 24px rgba(0, 0, 0, .22); } -.brand strong { display: block; font-size: .92rem; } -.brand small { display: block; margin-top: 1px; font-size: .65rem; } -.nav { display: grid; gap: 5px; } +.brand-mark i { position: absolute; width: 25px; height: 25px; border: 1px solid var(--purple); border-radius: 50%; box-shadow: 0 0 16px rgba(139, 92, 246, .5); } +.brand-mark b { position: relative; font-size: .72rem; letter-spacing: -.08em; } +.brand-copy strong, .brand-copy small { display: block; } +.brand-copy strong { font-size: .9rem; letter-spacing: -.01em; } +.brand-copy small { margin-top: 1px; font-size: .59rem; } +.sidebar-section { margin-bottom: 21px; } +.admin-section { margin-top: 2px; } +.nav-label { margin: 0 10px 8px; color: var(--muted-2); font-size: .58rem; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; } +.nav { display: grid; gap: 4px; } .nav a { + position: relative; display: flex; + min-height: 42px; align-items: center; gap: 11px; - min-height: 42px; padding: 9px 11px; - border-radius: 11px; - color: var(--muted); - font-size: .8rem; - font-weight: 650; - transition: .18s ease; -} -.nav a span { display: grid; width: 22px; place-items: center; color: var(--muted-2); font-size: 1rem; } -.nav a:hover { color: var(--text); background: var(--surface-2); } -.nav a.active { - color: #fff; - background: linear-gradient(90deg, rgba(139, 92, 246, .3), rgba(139, 92, 246, .08)); - box-shadow: inset 3px 0 var(--purple-bright); -} -[data-theme="light"] .nav a.active { color: #5631b9; } -.nav a.active span { color: var(--purple-bright); } -.nav-label { margin: 24px 11px 8px; color: var(--muted-2); font-size: .63rem; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; } -.sidebar-footer { display: grid; gap: 5px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border); } -.theme-toggle, .text-button { - width: 100%; - padding: 9px 11px; - border: 0; + border: 1px solid transparent; border-radius: 10px; color: var(--muted); + font-size: .76rem; + font-weight: 680; + transition: .16s ease; +} +.nav a .icon { width: 17px; height: 17px; color: var(--muted-2); } +.nav a:hover { color: var(--text); background: var(--surface-2); } +.nav a.active { + border-color: rgba(139, 92, 246, .18); + color: #fff; + background: rgba(139, 92, 246, .13); + box-shadow: inset 3px 0 var(--purple-light); +} +[data-theme="light"] .nav a.active { color: #5f38c6; } +.nav a.active .icon { color: var(--purple-light); } +.sidebar-insight { + position: relative; + margin: auto 2px 18px; + overflow: hidden; + padding: 15px; + border: 1px solid var(--border); + border-radius: 14px; + background: linear-gradient(145deg, rgba(73, 168, 255, .07), rgba(139, 92, 246, .08)); +} +.sidebar-insight > * { position: relative; z-index: 1; } +.sidebar-insight p { margin-bottom: 3px; color: var(--muted); font-size: .6rem; font-weight: 750; text-transform: uppercase; } +.sidebar-insight strong, .sidebar-insight small { display: block; } +.sidebar-insight strong { font-size: .83rem; } +.sidebar-insight small { margin-top: 7px; font-size: .57rem; line-height: 1.45; } +.insight-orb { position: absolute; width: 85px; height: 85px; right: -35px; bottom: -40px; border-radius: 50%; background: var(--purple); filter: blur(30px); opacity: .2; } +.sidebar-footer { display: flex; align-items: center; gap: 7px; padding-top: 15px; border-top: 1px solid var(--border); } +.admin-card { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; } +.avatar { display: grid; width: 31px; height: 31px; flex: 0 0 31px; place-items: center; border-radius: 9px; color: #fff; background: linear-gradient(145deg, #6442d1, #35236d); font-size: .7rem; font-weight: 850; } +.admin-card > span:last-child { min-width: 0; } +.admin-card strong, .admin-card small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-card strong { font-size: .66rem; } +.admin-card small { max-width: 105px; font-size: .52rem; } +.sidebar-actions { display: flex; gap: 3px; } +.icon-control, .menu-button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 0; + border-radius: 8px; + color: var(--muted); background: transparent; - text-align: left; cursor: pointer; } -.theme-toggle:hover, .text-button:hover { color: var(--text); background: var(--surface-2); } -.theme-toggle span:first-child { display: inline-block; width: 24px; color: var(--orange); } +.icon-control:hover, .menu-button:hover { color: var(--text); background: var(--surface-2); } +.icon-control .icon { width: 16px; height: 16px; } +.sidebar-scrim { display: none; } .main { min-height: 100vh; margin-left: var(--sidebar-width); } .topbar { + position: sticky; + top: 0; + z-index: 30; display: flex; - min-height: 92px; + min-height: 82px; align-items: center; - gap: 18px; - padding: 20px clamp(22px, 4vw, 52px); + gap: 16px; + padding: 16px clamp(22px, 3.2vw, 46px); border-bottom: 1px solid var(--border); - background: rgba(8, 10, 18, .55); - backdrop-filter: blur(18px); + background: color-mix(in srgb, var(--bg) 80%, transparent); + backdrop-filter: blur(20px); } -[data-theme="light"] .topbar { background: rgba(238, 240, 248, .72); } -.topbar > div:nth-child(2) { flex: 1; } -.topbar .eyebrow { margin-bottom: .25rem; } -.menu-button { display: none; border: 0; background: transparent; font-size: 1.3rem; cursor: pointer; } +.menu-button { display: none; } +.page-title { min-width: 0; flex: 1; } +.page-title .eyebrow { margin-bottom: .25rem; } .month-switcher { display: flex; align-items: center; - gap: 6px; - padding: 5px; + gap: 3px; + padding: 4px; border: 1px solid var(--border); - border-radius: 12px; + border-radius: 10px; background: var(--surface); } -.month-switcher a { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 8px; color: var(--muted); } +.month-switcher a { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 7px; color: var(--muted); font-size: 1.25rem; } .month-switcher a:hover { color: var(--text); background: var(--surface-2); } -.month-switcher input { width: 143px; border: 0; color: var(--text); background: transparent; font-weight: 750; text-align: center; } -.page-content { display: grid; gap: 22px; padding: 28px clamp(22px, 4vw, 52px) 60px; } +.month-switcher input { width: 137px; min-height: 28px; padding: 2px 5px; border: 0; color: var(--text); background: transparent; font-size: .7rem; font-weight: 760; text-align: center; } +.page-content { display: grid; gap: 20px; padding: 25px clamp(22px, 3.2vw, 46px) 52px; } +.page-content > * { min-width: 0; } +.app-footer { display: flex; justify-content: space-between; margin: 0 clamp(22px, 3.2vw, 46px); padding: 17px 0 24px; border-top: 1px solid var(--border); color: var(--muted-2); font-size: .58rem; } .flash { display: flex; align-items: center; gap: 10px; - margin: 18px clamp(22px, 4vw, 52px) -6px; - padding: 12px 15px; + margin: 16px clamp(22px, 3.2vw, 46px) -5px; + padding: 11px 14px; border: 1px solid; - border-radius: 12px; - font-size: .79rem; + border-radius: 11px; + font-size: .74rem; } -.flash > span { display: grid; width: 23px; height: 23px; place-items: center; border-radius: 50%; font-weight: 900; } -.flash.success { border-color: rgba(67, 245, 164, .3); color: var(--green); background: rgba(67, 245, 164, .07); } -.flash.error { border-color: rgba(255, 83, 100, .3); color: var(--red); background: rgba(255, 83, 100, .08); } -.flash button { margin-left: auto; border: 0; color: inherit; background: transparent; cursor: pointer; } +.flash > span { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 50%; font-weight: 900; } +.flash p { margin: 0; } +.flash.success { border-color: rgba(73, 232, 164, .28); color: var(--green); background: rgba(73, 232, 164, .06); } +.flash.error { border-color: rgba(255, 100, 115, .3); color: var(--red); background: rgba(255, 100, 115, .07); } +.flash button { display: grid; width: 27px; height: 27px; margin-left: auto; place-items: center; padding: 0; border: 0; color: inherit; background: transparent; cursor: pointer; } +.flash button .icon { width: 14px; height: 14px; } .card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); - backdrop-filter: blur(16px); + backdrop-filter: blur(14px); } -.glow-purple { box-shadow: var(--shadow), inset 0 0 50px rgba(139, 92, 246, .06); } -.glow-blue { box-shadow: var(--shadow), inset 0 0 50px rgba(46, 169, 255, .06); } -.glow-green { box-shadow: var(--shadow), inset 0 0 50px rgba(67, 245, 164, .06); } -.hero-grid { display: grid; grid-template-columns: 1.75fr repeat(3, 1fr); gap: 16px; } -.hero-balance { position: relative; display: flex; min-height: 164px; align-items: center; justify-content: space-between; overflow: hidden; padding: 24px 27px; } -.hero-balance::after { position: absolute; width: 180px; height: 180px; right: -60px; bottom: -110px; border-radius: 50%; background: var(--purple); filter: blur(45px); opacity: .17; content: ""; } -.hero-amount { margin: 7px 0 4px; font-size: clamp(2rem, 3.5vw, 3rem); font-weight: 850; letter-spacing: -.065em; } -.balance-ring { - --angle: calc(var(--progress) * 3.6deg); - position: relative; - z-index: 1; - display: grid; - width: 92px; - height: 92px; - flex: 0 0 92px; - place-content: center; - border-radius: 50%; - background: radial-gradient(circle 35px, var(--surface-solid) 97%, transparent 100%), conic-gradient(var(--purple-bright) var(--angle), rgba(255,255,255,.08) 0); - text-align: center; -} -.balance-ring span { font-size: 1.1rem; font-weight: 850; line-height: 1; } -.balance-ring small { margin-top: 3px; font-size: .63rem; text-transform: uppercase; } -.metric-card { position: relative; min-height: 164px; padding: 22px; overflow: hidden; } -.metric-card p { margin: 16px 0 5px; color: var(--muted); font-size: .72rem; font-weight: 700; } -.metric-card strong { display: block; font-size: 1.45rem; letter-spacing: -.045em; } -.metric-card small { display: block; margin-top: 6px; font-size: .65rem; } -.metric-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; font-size: .9rem; font-weight: 900; } -.metric-icon.blue { color: var(--blue); background: rgba(46, 169, 255, .12); } -.metric-icon.pink { color: var(--pink); background: rgba(255, 79, 163, .12); } -.metric-icon.orange { color: var(--orange); background: rgba(255, 159, 67, .12); } -.metric-icon.green { color: var(--green); background: rgba(67, 245, 164, .12); } - -.content-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; } -.span-2 { grid-column: span 2; } -.stack { display: grid; align-content: start; gap: 20px; } -.content-grid .card, .calendar-card, .page-content > .card { padding: 22px; } -.card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 20px; } +.panel { min-width: 0; padding: 21px; } +.stack { display: grid; align-content: start; gap: 18px; } +.card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; } .card-heading .eyebrow { margin-bottom: 3px; } .button { display: inline-flex; - min-height: 40px; + min-height: 38px; align-items: center; justify-content: center; - padding: 9px 16px; - border: 1px solid var(--border); - border-radius: 10px; + gap: 7px; + padding: 8px 14px; + border: 1px solid var(--border-strong); + border-radius: 9px; color: var(--text); background: var(--surface-2); - font-size: .77rem; + font-size: .7rem; font-weight: 800; cursor: pointer; - transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; + transition: transform .15s ease, border-color .15s ease, background .15s ease; } -.button:hover { transform: translateY(-1px); border-color: var(--border-strong); } -.button.primary { border-color: rgba(139, 92, 246, .45); color: #fff; background: linear-gradient(135deg, var(--purple), #6841d6); box-shadow: 0 9px 24px rgba(139, 92, 246, .22); } +.button:hover { transform: translateY(-1px); border-color: rgba(169, 139, 255, .4); } +.button.primary { border-color: rgba(139, 92, 246, .55); color: #fff; background: #7448df; box-shadow: 0 8px 20px rgba(91, 54, 190, .22); } +.button.glass { background: rgba(255, 255, 255, .06); backdrop-filter: blur(8px); } .button.secondary { background: transparent; } -.button.small { min-height: 33px; padding: 6px 11px; font-size: .7rem; } +.button.small { min-height: 32px; padding: 6px 10px; font-size: .64rem; } .button.full { width: 100%; } -.text-link { padding: 0; border: 0; color: var(--purple-bright); background: none; font-size: .73rem; font-weight: 750; cursor: pointer; } -.text-link:hover { text-decoration: underline; } -.icon-button { display: grid; width: 31px; height: 31px; flex: 0 0 31px; place-items: center; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; } -.icon-button:hover { color: var(--text); border-color: var(--border-strong); } -.icon-button.danger:hover { color: var(--red); border-color: rgba(255, 83, 100, .35); background: rgba(255, 83, 100, .07); } +.button .icon { width: 15px; height: 15px; } +.top-action { min-height: 36px; } +.text-link { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; color: var(--purple-light); background: none; font-size: .67rem; font-weight: 750; cursor: pointer; } +.text-link:hover { color: var(--text); } +.icon-button, .micro-button { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 30px; + place-items: center; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--muted); + background: transparent; + cursor: pointer; +} +.icon-button:hover, .micro-button:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); } +.icon-button.danger:hover, .micro-button.danger:hover { color: var(--red); border-color: rgba(255, 100, 115, .3); background: rgba(255, 100, 115, .06); } +.icon-button .icon, .micro-button .icon { width: 14px; height: 14px; } +.micro-button { width: 25px; height: 25px; flex-basis: 25px; border: 0; } +.overview-hero { + position: relative; + display: grid; + min-height: 285px; + grid-template-columns: minmax(0, 1.45fr) minmax(250px, .55fr); + align-items: center; + overflow: hidden; + border-color: rgba(139, 92, 246, .18); + background: #0b0f18; +} +.overview-hero, .dashboard-layout, .dashboard-main, .dashboard-side, +.dashboard-main > *, .dashboard-side > * { min-width: 0; } +.hero-art { + position: absolute; + inset: 0; + background: + linear-gradient(90deg, rgba(8, 12, 20, .99) 0%, rgba(8, 12, 20, .92) 35%, rgba(8, 12, 20, .28) 77%, rgba(8, 12, 20, .12) 100%), + url("/assets/images/financial-flow.png") center / cover no-repeat; + opacity: .92; +} +[data-theme="light"] .overview-hero { color: #fff; } +.hero-copy, .hero-gauge { position: relative; z-index: 1; } +.hero-copy { padding: 32px clamp(26px, 4vw, 48px); } +.status-line { display: flex; align-items: center; gap: 8px; color: #aeb5c7; font-size: .62rem; font-weight: 700; } +.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 11px var(--green); } +.status-divider { width: 3px; height: 3px; border-radius: 50%; background: #5b6477; } +.hero-label { margin: 25px 0 4px; color: #aeb5c7; font-size: .73rem; font-weight: 700; } +.hero-value { color: #fff; font-size: clamp(2.65rem, 5vw, 4.25rem); font-weight: 820; line-height: 1; letter-spacing: -.07em; } +.hero-value.negative { color: var(--red); } +.hero-summary { margin: 8px 0 0; color: #929bad; font-size: .72rem; } +.hero-summary span { margin: 0 5px; color: #535d70; } +.hero-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 25px; } +.hero-gauge { display: flex; align-items: center; gap: 18px; padding: 28px 35px 28px 10px; } +.gauge-ring { + --angle: calc(var(--progress) * 3.6deg); + display: grid; + width: 116px; + height: 116px; + flex: 0 0 116px; + place-content: center; + border-radius: 50%; + background: + radial-gradient(circle 45px, #111522 97%, transparent 100%), + conic-gradient(var(--green) var(--angle), rgba(255, 255, 255, .09) 0); + box-shadow: 0 0 35px rgba(73, 232, 164, .08); + text-align: center; +} +.gauge-ring span { color: #fff; font-size: 1.3rem; font-weight: 850; line-height: 1; } +.gauge-ring small { margin-top: 5px; color: #8f98aa; font-size: .55rem; font-weight: 750; text-transform: uppercase; } +.gauge-note { min-width: 0; } +.gauge-note span, .gauge-note strong, .gauge-note small { display: block; } +.gauge-note span { color: #929bad; font-size: .58rem; text-transform: uppercase; } +.gauge-note strong { margin-top: 4px; color: #fff; font-size: 1rem; } +.gauge-note small { margin-top: 4px; color: #737d92; font-size: .55rem; line-height: 1.4; } + +.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.metric-card { min-height: 148px; padding: 18px; } +.metric-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.metric-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 10px; } +.metric-icon .icon { width: 16px; height: 16px; } +.metric-icon.blue { color: var(--blue); background: rgba(73, 168, 255, .1); } +.metric-icon.pink { color: var(--pink); background: rgba(255, 91, 157, .1); } +.metric-icon.purple { color: var(--purple-light); background: rgba(139, 92, 246, .11); } +.metric-icon.green { color: var(--green); background: rgba(73, 232, 164, .1); } +.metric-icon.orange { color: var(--orange); background: rgba(255, 173, 92, .1); } +.trend-tag { padding: 4px 7px; border-radius: 20px; color: var(--green); background: rgba(73, 232, 164, .07); font-size: .54rem; font-weight: 800; white-space: nowrap; } +.trend-tag.warning, .trend-tag.negative { color: var(--orange); background: rgba(255, 173, 92, .08); } +.metric-card > p { margin: 14px 0 4px; color: var(--muted); font-size: .66rem; font-weight: 700; } +.metric-card > strong { display: block; font-size: 1.43rem; line-height: 1.1; letter-spacing: -.045em; } +.metric-card > small { display: block; margin-top: 7px; font-size: .57rem; line-height: 1.45; } + +.dashboard-layout { display: grid; grid-template-columns: minmax(0, 1.9fr) minmax(300px, .8fr); gap: 18px; align-items: start; } .budget-list { display: grid; } -.budget-row { display: flex; align-items: center; gap: 13px; padding: 15px 0; border-top: 1px solid var(--border); } +.budget-row { + display: grid; + grid-template-columns: minmax(185px, .8fr) minmax(220px, 1.25fr) auto; + align-items: center; + gap: 18px; + padding: 15px 0; + border-top: 1px solid var(--border); +} .budget-row:first-child { padding-top: 2px; border-top: 0; } -.category-dot { width: 9px; height: 38px; flex: 0 0 9px; border-radius: 9px; background: var(--dot); box-shadow: 0 0 13px color-mix(in srgb, var(--dot), transparent 50%); } -.budget-main { min-width: 0; flex: 1; } -.budget-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } -.budget-title strong { overflow: hidden; font-size: .8rem; text-overflow: ellipsis; white-space: nowrap; } -.budget-title span, .budget-meta { color: var(--muted); font-size: .63rem; } -.progress-track { height: 5px; margin: 9px 0 6px; overflow: hidden; border-radius: 5px; background: rgba(255,255,255,.07); } -[data-theme="light"] .progress-track { background: rgba(30,35,60,.08); } -.progress-track span { display: block; height: 100%; border-radius: inherit; background: var(--bar); box-shadow: 0 0 12px color-mix(in srgb, var(--bar), transparent 55%); } -.budget-meta { display: flex; justify-content: space-between; } -.budget-row .target { width: 80px; font-size: .79rem; text-align: right; } +.budget-identity { display: flex; min-width: 0; align-items: center; gap: 10px; } +.category-mark { + display: grid; + width: 35px; + height: 35px; + flex: 0 0 35px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--dot), transparent 60%); + border-radius: 10px; + color: var(--dot); + background: color-mix(in srgb, var(--dot), transparent 89%); + font-size: .64rem; + font-weight: 900; +} +.budget-identity > div { min-width: 0; } +.budget-identity strong, .budget-identity span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.budget-identity strong { font-size: .72rem; } +.budget-identity span { margin-top: 2px; color: var(--muted); font-size: .56rem; } +.budget-progress { min-width: 0; } +.progress-labels { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .55rem; } +.progress-labels strong { color: var(--text); font-size: .58rem; } +.progress-track { height: 5px; margin: 7px 0 5px; overflow: hidden; border-radius: 5px; background: var(--surface-3); } +.progress-track span { display: block; max-width: 100%; height: 100%; border-radius: inherit; background: var(--bar); box-shadow: 0 0 10px color-mix(in srgb, var(--bar), transparent 45%); } +.budget-progress small { display: block; font-size: .53rem; } +.row-actions, .table-actions { display: flex; justify-content: flex-end; gap: 5px; } +.row-actions form, .table-actions form { display: flex; } .trend-chart { display: block; width: 100%; max-width: 100%; } -.trend-chart.large { min-height: 260px; } -.accent-card { border-color: rgba(139, 92, 246, .23); } -.stats-card { background: linear-gradient(135deg, rgba(46,169,255,.09), rgba(139,92,246,.06)), var(--surface); } -.stat-trio { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; text-align: center; } -.stat-trio div { padding: 12px 4px; border: 1px solid var(--border); border-radius: 11px; background: rgba(255,255,255,.025); } -.stat-trio strong, .stat-trio span { display: block; } -.stat-trio strong { color: var(--blue); font-size: 1.25rem; } -.stat-trio span { margin-top: 2px; color: var(--muted); font-size: .58rem; } -.mini-list { display: grid; } -.mini-list > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid var(--border); } -.mini-list > div:first-child { padding-top: 0; border-top: 0; } -.mini-list strong, .mini-list small { display: block; font-size: .72rem; } -.mini-list small { margin-top: 2px; font-size: .58rem; } +.trend-chart.large { min-height: 270px; } +.activity-list, .income-list, .due-list { display: grid; width: 100%; min-width: 0; } +.activity-row, .income-list > div { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 10px; + padding: 11px 0; + border-top: 1px solid var(--border); +} +.activity-row:first-child, .income-list > div:first-child { padding-top: 0; border-top: 0; } +.activity-icon { display: grid; width: 31px; height: 31px; flex: 0 0 31px; place-items: center; border-radius: 9px; color: var(--blue); background: rgba(73, 168, 255, .09); } +.activity-icon .icon { width: 14px; height: 14px; } +.activity-icon.expense { color: var(--pink); background: rgba(255, 91, 157, .09); } +.activity-icon.saved { color: var(--purple-light); background: rgba(139, 92, 246, .1); } +.activity-icon.restored, .activity-icon.income { color: var(--green); background: rgba(73, 232, 164, .09); } +.activity-row > div, .income-list p { min-width: 0; flex: 1; margin: 0; } +.activity-row strong, .activity-row small, .income-list strong, .income-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-row strong, .income-list strong { font-size: .68rem; } +.activity-row small, .income-list small { margin-top: 2px; font-size: .54rem; } +.activity-row > b, .income-list > div > b { font-size: .66rem; } +.plan-card { border-color: rgba(139, 92, 246, .16); } +.health-score { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid var(--border); border-radius: 12px; background: rgba(139, 92, 246, .045); } +.health-score > span { + --angle: calc(var(--score) * 3.6deg); + display: grid; + width: 55px; + height: 55px; + flex: 0 0 55px; + place-items: center; + border-radius: 50%; + background: radial-gradient(circle 21px, var(--surface-solid) 97%, transparent 100%), conic-gradient(var(--purple-light) var(--angle), var(--surface-3) 0); + font-size: .72rem; + font-weight: 850; +} +.health-score strong, .health-score small { display: block; } +.health-score strong { font-size: .68rem; } +.health-score small { margin-top: 3px; font-size: .53rem; } +.plan-facts { margin: 13px 0 0; } +.plan-facts > div { display: flex; justify-content: space-between; gap: 12px; padding: 9px 0; border-top: 1px solid var(--border); font-size: .62rem; } +.plan-facts dt { color: var(--muted); } +.plan-facts dd { font-weight: 800; } +.rollover-refresh { display: flex; justify-content: flex-end; margin-top: 2px; } +.rule-summary { display: flex; align-items: center; gap: 10px; margin-top: 11px; padding: 11px; border-radius: 10px; background: var(--surface-2); } +.rule-summary > span { color: var(--purple-light); } +.rule-summary p { margin: 0; } +.rule-summary strong, .rule-summary small { display: block; } +.rule-summary strong { font-size: .62rem; } +.rule-summary small { margin-top: 2px; font-size: .51rem; } +.due-list > div { display: flex; align-items: center; gap: 9px; padding: 10px 0; border-top: 1px solid var(--border); } +.due-list > div:first-child { padding-top: 0; border-top: 0; } +.date-tile { display: grid; width: 35px; height: 39px; flex: 0 0 35px; place-content: center; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); text-align: center; } +.date-tile b { font-size: .72rem; line-height: 1; } +.date-tile small { margin-top: 3px; font-size: .46rem; } +.due-list p { min-width: 0; flex: 1; margin: 0; } +.due-list strong, .due-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.due-list strong { font-size: .65rem; } +.due-list small { margin-top: 2px; font-size: .52rem; } +.due-list > div > b { font-size: .62rem; } +.usage-card { padding: 17px 19px; background: linear-gradient(135deg, rgba(73, 168, 255, .06), rgba(139, 92, 246, .055)), var(--surface); } +.usage-card > div { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; } +.usage-card span { display: block; padding: 8px 3px; border-left: 1px solid var(--border); text-align: center; } +.usage-card span:first-child { border-left: 0; } +.usage-card strong, .usage-card small { display: block; } +.usage-card strong { color: var(--blue); font-size: 1rem; } +.usage-card small { margin-top: 1px; font-size: .48rem; } -label { display: grid; gap: 6px; color: var(--muted); font-size: .68rem; font-weight: 750; } +.page-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; } +.page-intro h2 { font-size: 1.35rem; } +.page-intro p:last-child { max-width: 620px; margin: 7px 0 0; color: var(--muted); font-size: .72rem; } +.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 13px; } +.summary-card { display: flex; min-height: 94px; align-items: center; gap: 12px; padding: 17px; } +.summary-card small, .summary-card strong { display: block; } +.summary-card small { font-size: .56rem; text-transform: uppercase; } +.summary-card strong { margin-top: 4px; font-size: 1.12rem; letter-spacing: -.035em; } + +label { display: grid; gap: 6px; color: var(--muted); font-size: .64rem; font-weight: 750; } input, select, textarea { width: 100%; - min-height: 41px; - padding: 9px 11px; + min-height: 40px; + padding: 9px 10px; border: 1px solid var(--border); - border-radius: 9px; - outline: none; + border-radius: 8px; + outline: 0; color: var(--text); background: var(--surface-2); transition: border-color .15s ease, box-shadow .15s ease; } textarea { min-height: auto; resize: vertical; } -input:focus, select:focus, textarea:focus { border-color: rgba(139, 92, 246, .65); box-shadow: 0 0 0 3px rgba(139, 92, 246, .1); } +input:focus, select:focus, textarea:focus { border-color: rgba(139, 92, 246, .6); box-shadow: 0 0 0 3px rgba(139, 92, 246, .09); } input[type="color"] { padding: 5px; } -input[type="checkbox"] { width: 17px; height: 17px; min-height: 17px; accent-color: var(--purple); } +input[type="checkbox"] { width: 16px; height: 16px; min-height: 16px; accent-color: var(--purple); } .money-input { position: relative; } -.money-input span { position: absolute; z-index: 1; left: 11px; top: 50%; color: var(--muted); transform: translateY(-50%); } -.money-input input { padding-left: 27px; } -.stack-form { display: grid; gap: 14px; } -.stack-form.compact { gap: 11px; } -.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.money-input > span { position: absolute; z-index: 1; left: 10px; top: 50%; color: var(--muted); transform: translateY(-50%); } +.money-input input { padding-left: 25px; } +.stack-form { display: grid; gap: 13px; } +.stack-form.compact { gap: 10px; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; } .form-grid .span-2 { grid-column: 1 / -1; } -.form-note { margin: 13px 0 0; color: var(--muted); font-size: .65rem; } -.check-row { display: flex; align-items: flex-start; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: rgba(255,255,255,.025); cursor: pointer; } +.form-note { margin: 12px 0 0; color: var(--muted); font-size: .58rem; } +.check-row { display: flex; align-items: flex-start; gap: 9px; padding: 11px; border: 1px solid var(--border); border-radius: 9px; background: rgba(255, 255, 255, .018); cursor: pointer; } .check-row input { margin-top: 2px; } .check-row span, .check-row strong, .check-row small { display: block; } -.check-row strong { color: var(--text); font-size: .72rem; } -.check-row small { margin-top: 2px; font-weight: 500; } +.check-row strong { color: var(--text); font-size: .66rem; } +.check-row small { margin-top: 2px; font-size: .54rem; font-weight: 500; } .check-row.slim { align-items: center; padding: 4px 0; border: 0; background: transparent; } -.check-row.slim span { color: var(--muted); font-size: .68rem; } .inline-form { display: flex; } -.top-gap { margin-top: 11px; } -.inline-fields { display: grid; grid-template-columns: 1.2fr .35fr 1fr auto; align-items: end; gap: 12px; } -.empty-state { padding: 30px 15px; border: 1px dashed var(--border-strong); border-radius: 14px; color: var(--muted); text-align: center; } -.empty-state strong { color: var(--text); } -.empty-state p { margin: 5px 0 0; font-size: .72rem; } -.empty-state.compact { padding: 20px 10px; } +.top-gap { margin-top: 10px; } +.inline-fields { display: grid; grid-template-columns: 1.2fr .32fr 1fr auto; align-items: end; gap: 11px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); } +.danger-link { color: var(--red); } +.danger-link .icon { width: 13px; height: 13px; } +.push-left { margin-right: auto; } +.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 18px; } -dialog { width: min(570px, calc(100vw - 30px)); padding: 0; border: 0; border-radius: 20px; color: var(--text); background: transparent; box-shadow: 0 30px 100px rgba(0,0,0,.55); } -dialog::backdrop { background: rgba(3, 4, 9, .78); backdrop-filter: blur(6px); } -.dialog-card { padding: 24px; border: 1px solid var(--border-strong); border-radius: 20px; background: var(--surface-solid); } -.dialog-card .card-heading { margin-bottom: 19px; } +dialog { width: min(590px, calc(100vw - 28px)); max-height: calc(100vh - 28px); padding: 0; overflow: auto; border: 0; border-radius: 17px; color: var(--text); background: transparent; box-shadow: 0 30px 100px rgba(0, 0, 0, .55); } +dialog::backdrop { background: rgba(3, 5, 10, .78); backdrop-filter: blur(6px); } +.dialog-card { padding: 22px; border: 1px solid var(--border-strong); border-radius: 17px; background: var(--surface-solid); } -.summary-strip { display: grid; grid-template-columns: repeat(3, 1fr) auto; align-items: center; gap: 12px; padding: 17px 20px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); } -.summary-strip div { padding-right: 18px; border-right: 1px solid var(--border); } -.summary-strip span, .summary-strip strong { display: block; } -.summary-strip span { color: var(--muted); font-size: .62rem; } -.summary-strip strong { margin-top: 2px; font-size: 1rem; } -.search-box { position: relative; width: min(240px, 100%); } -.search-box span { position: absolute; z-index: 1; left: 10px; top: 50%; transform: translateY(-50%); } -.search-box input { min-height: 34px; padding: 6px 9px 6px 29px; font-size: .68rem; } +.empty-state { display: grid; min-height: 210px; place-items: center; align-content: center; padding: 27px 15px; border: 1px dashed var(--border-strong); border-radius: 13px; color: var(--muted); text-align: center; } +.empty-state.compact { min-height: 130px; padding: 20px 10px; } +.empty-state strong { color: var(--text); font-size: .75rem; } +.empty-state p { max-width: 340px; margin: 5px 0 13px; font-size: .63rem; } +.empty-icon { display: grid; width: 37px; height: 37px; margin-bottom: 10px; place-items: center; border-radius: 10px; color: var(--purple-light); background: rgba(139, 92, 246, .1); } +.empty-icon .icon { width: 17px; height: 17px; } + +.search-box { position: relative; width: min(285px, 100%); } +.search-box .icon { position: absolute; z-index: 1; left: 9px; top: 50%; width: 14px; height: 14px; transform: translateY(-50%); } +.search-box input { min-height: 34px; padding: 6px 9px 6px 30px; font-size: .63rem; } .table-wrap { width: 100%; overflow-x: auto; } table { width: 100%; border-collapse: collapse; } -th { padding: 10px; border-bottom: 1px solid var(--border-strong); color: var(--muted); font-size: .61rem; letter-spacing: .08em; text-align: left; text-transform: uppercase; white-space: nowrap; } -td { padding: 13px 10px; border-bottom: 1px solid var(--border); font-size: .72rem; vertical-align: middle; } +th { padding: 10px; border-bottom: 1px solid var(--border-strong); color: var(--muted); font-size: .55rem; letter-spacing: .08em; text-align: left; text-transform: uppercase; white-space: nowrap; } +td { padding: 12px 10px; border-bottom: 1px solid var(--border); font-size: .67rem; vertical-align: middle; } tbody tr:last-child td { border-bottom: 0; } -td small { display: block; margin-top: 3px; font-size: .59rem; } -.pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 20px; background: var(--surface-2); font-size: .62rem; } +td > small, td > strong { display: block; } +td > small { margin-top: 3px; font-size: .53rem; } +.table-date { display: block; font-weight: 750; white-space: nowrap; } +.table-date small { display: block; margin-top: 1px; font-size: .5rem; font-weight: 500; } +.amount-negative { color: var(--pink); } +.pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 7px; border-radius: 20px; background: var(--surface-2); font-size: .56rem; white-space: nowrap; } .pill i, .category-bars i { width: 6px; height: 6px; border-radius: 50%; background: var(--dot); } -.calendar-toolbar { display: flex; align-items: center; justify-content: space-between; } -.calendar-toolbar p:last-child { margin-bottom: 0; font-size: .72rem; } -.calendar-card { padding: 0 !important; overflow: hidden; } -.calendar-weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } +.calendar-toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; } +.calendar-toolbar p:last-child { margin-bottom: 0; font-size: .68rem; } +.calendar-card { padding: 0 !important; overflow-x: auto; } +.calendar-weekdays, .calendar-grid { display: grid; min-width: 760px; grid-template-columns: repeat(7, minmax(0, 1fr)); } .calendar-weekdays { border-bottom: 1px solid var(--border); background: var(--surface-2); } -.calendar-weekdays span { padding: 11px; color: var(--muted); font-size: .62rem; font-weight: 800; text-align: center; text-transform: uppercase; } -.calendar-day { position: relative; min-height: 130px; padding: 9px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.calendar-weekdays span { padding: 10px; color: var(--muted); font-size: .56rem; font-weight: 800; text-align: center; text-transform: uppercase; } +.calendar-day { min-height: 125px; padding: 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } .calendar-day:nth-child(7n) { border-right: 0; } -.calendar-day.blank { background: rgba(0,0,0,.08); } -.day-number { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; color: var(--muted); font-size: .67rem; font-weight: 800; } -.calendar-day.today .day-number { color: #fff; background: var(--purple); box-shadow: 0 0 16px rgba(139,92,246,.42); } -.calendar-event { display: flex; align-items: flex-start; gap: 5px; margin-top: 6px; padding: 6px 7px; border-left: 2px solid var(--purple); border-radius: 6px; background: rgba(139,92,246,.1); font-size: .58rem; } -.calendar-event.bill { border-color: var(--pink); background: rgba(255,79,163,.09); } -.calendar-event.payday { border-color: var(--green); background: rgba(67,245,164,.08); } -.calendar-event.deadline { border-color: var(--orange); background: rgba(255,159,67,.08); } +.calendar-day.blank { background: rgba(0, 0, 0, .06); } +.day-number { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; color: var(--muted); font-size: .62rem; font-weight: 800; } +.calendar-day.today .day-number { color: #fff; background: var(--purple); box-shadow: 0 0 15px rgba(139, 92, 246, .36); } +.calendar-event { display: flex; align-items: flex-start; gap: 5px; margin-top: 5px; padding: 5px 6px; border-left: 2px solid var(--purple); border-radius: 5px; background: rgba(139, 92, 246, .08); font-size: .53rem; } +.calendar-event.bill { border-color: var(--pink); background: rgba(255, 91, 157, .08); } +.calendar-event.payday { border-color: var(--green); background: rgba(73, 232, 164, .07); } +.calendar-event.deadline { border-color: var(--orange); background: rgba(255, 173, 92, .07); } .calendar-event > div { min-width: 0; flex: 1; } .calendar-event strong, .calendar-event small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .calendar-event button { padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; } -.comparison-grid { grid-template-columns: 2fr 1fr; } -.comparison-metrics { grid-template-columns: repeat(4, 1fr); } -.category-bars { display: grid; gap: 18px; } -.category-bars p { display: flex; justify-content: space-between; margin-bottom: 0; font-size: .68rem; } -.category-bars p span { display: flex; align-items: center; gap: 7px; } -.category-bars small { display: block; margin-top: -1px; font-size: .58rem; } +.hero-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.comparison-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 18px; } +.content-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; } +.content-grid .card, .page-content > .card, .calendar-card { padding: 21px; } +.span-2 { grid-column: span 2; } +.category-bars { display: grid; gap: 16px; } +.category-bars p { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 0; font-size: .62rem; } +.category-bars p span { display: flex; align-items: center; gap: 6px; } +.category-bars small { display: block; margin-top: -1px; font-size: .52rem; } -.dead-hero { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 30px; padding: 28px 32px; background: linear-gradient(110deg, rgba(46,169,255,.08), rgba(139,92,246,.08)), var(--surface); } -.dead-hero h2 { font-size: 1.45rem; } -.dead-hero p:last-child { max-width: 570px; margin: 10px 0 0; color: var(--muted); } -.visible-balance, .concealed-balance { min-width: 220px; font-size: 2rem; font-weight: 850; letter-spacing: -.05em; text-align: center; } -.concealed-balance { padding: 20px; border: 1px solid rgba(46,169,255,.28); border-radius: 16px; color: var(--blue); background: rgba(46,169,255,.07); cursor: pointer; } +.dead-hero { display: flex; min-height: 195px; align-items: center; justify-content: space-between; gap: 30px; padding: 28px 32px !important; border-color: rgba(139, 92, 246, .18); background: linear-gradient(110deg, rgba(73, 168, 255, .06), rgba(139, 92, 246, .08)), var(--surface); } +.dead-hero > div:first-child { max-width: 650px; } +.dead-hero > div:first-child .metric-icon { margin-bottom: 17px; } +.dead-hero h2 { font-size: 1.4rem; } +.dead-hero p:last-child { margin: 9px 0 0; color: var(--muted); font-size: .72rem; } +.visible-balance, .concealed-balance { min-width: 220px; font-size: 1.9rem; font-weight: 850; letter-spacing: -.05em; text-align: center; } +.concealed-balance { padding: 18px; border: 1px solid rgba(73, 168, 255, .24); border-radius: 14px; color: var(--blue); background: rgba(73, 168, 255, .06); cursor: pointer; } .concealed-balance .revealed { display: none; } .concealed-balance.reveal .masked { display: none; } .concealed-balance.reveal .revealed { display: block; } -.concealed-balance small { display: block; margin-top: 4px; font-size: .58rem; font-weight: 650; letter-spacing: 0; } +.concealed-balance small { display: block; margin-top: 4px; font-size: .52rem; font-weight: 650; letter-spacing: 0; } .dead-grid { grid-template-columns: repeat(2, 1fr); } -.rule-list { display: grid; gap: 15px; } -.rule-list > div { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; background: rgba(255,255,255,.02); } +.rule-list { display: grid; gap: 11px; } +.rule-list > div { display: flex; align-items: center; gap: 11px; padding: 11px; border: 1px solid var(--border); border-radius: 10px; background: rgba(255, 255, 255, .014); } .rule-list p, .rule-list strong, .rule-list small { display: block; margin: 0; } -.rule-list strong { font-size: .72rem; } -.rule-list small { margin-top: 3px; font-size: .6rem; } +.rule-list strong { font-size: .66rem; } +.rule-list small { margin-top: 2px; font-size: .54rem; } -.settings-grid { display: grid; grid-template-columns: .7fr repeat(2, 1fr); gap: 20px; align-items: start; } -.settings-nav { position: sticky; top: 20px; display: grid; gap: 5px; padding: 18px !important; } -.settings-nav .eyebrow { padding: 0 8px 7px; } -.settings-nav a { padding: 8px; border-radius: 8px; color: var(--muted); font-size: .7rem; font-weight: 700; } +.settings-grid { display: grid; grid-template-columns: .65fr repeat(2, 1fr); gap: 18px; align-items: start; } +.settings-nav { position: sticky; top: 103px; display: grid; gap: 4px; padding: 17px !important; } +.settings-nav .eyebrow { padding: 0 7px 6px; } +.settings-nav a { padding: 7px; border-radius: 7px; color: var(--muted); font-size: .65rem; font-weight: 700; } .settings-nav a:hover { color: var(--text); background: var(--surface-2); } -.toggle-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } -.toggle-card { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: rgba(255,255,255,.02); cursor: pointer; } +.toggle-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; } +.toggle-card { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px; border: 1px solid var(--border); border-radius: 10px; background: rgba(255, 255, 255, .014); cursor: pointer; } .toggle-card span, .toggle-card strong, .toggle-card small { display: block; } -.toggle-card strong { color: var(--text); font-size: .7rem; } -.toggle-card small { margin-top: 2px; font-size: .56rem; font-weight: 500; } +.toggle-card strong { color: var(--text); font-size: .64rem; } +.toggle-card small { margin-top: 2px; font-size: .51rem; font-weight: 500; } .toggle-card input { position: absolute; opacity: 0; pointer-events: none; } -.toggle-card i { position: relative; width: 35px; height: 20px; flex: 0 0 35px; border-radius: 20px; background: var(--muted-2); transition: .18s; } -.toggle-card i::after { position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: .18s; content: ""; } -.toggle-card input:checked + i { background: var(--purple); box-shadow: 0 0 14px rgba(139,92,246,.3); } +.toggle-card i { position: relative; width: 34px; height: 19px; flex: 0 0 34px; border-radius: 20px; background: var(--muted-2); transition: .18s; } +.toggle-card i::after { position: absolute; width: 13px; height: 13px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: .18s; content: ""; } +.toggle-card input:checked + i { background: var(--purple); box-shadow: 0 0 13px rgba(139, 92, 246, .26); } .toggle-card input:checked + i::after { transform: translateX(15px); } -.status-badge { padding: 5px 8px; border-radius: 20px; font-size: .58rem; font-weight: 850; text-transform: uppercase; } -.status-badge.success { color: var(--green); background: rgba(67,245,164,.1); } -.status-badge.warning { color: var(--orange); background: rgba(255,159,67,.1); } -.sticky-save { position: sticky; bottom: 18px; width: 100%; z-index: 5; } -.settings-actions { display: flex; justify-content: flex-end; gap: 10px; } -.setup-secret { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; padding: 23px; border-color: rgba(67,245,164,.3); } -.setup-secret p { margin: 6px 0 0; color: var(--muted); } -.setup-secret > code { padding: 12px 15px; font-size: 1.1rem; letter-spacing: .12em; } +.status-badge { padding: 5px 8px; border-radius: 20px; font-size: .52rem; font-weight: 850; text-transform: uppercase; } +.status-badge.success { color: var(--green); background: rgba(73, 232, 164, .09); } +.status-badge.warning { color: var(--orange); background: rgba(255, 173, 92, .09); } +.sticky-save { position: sticky; bottom: 16px; z-index: 5; width: 100%; } +.settings-actions { display: flex; justify-content: flex-end; gap: 9px; } +.setup-secret { display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: center; padding: 21px !important; border-color: rgba(73, 232, 164, .27); } +.setup-secret p { margin: 5px 0 0; color: var(--muted); } +.setup-secret > code { padding: 11px 14px; font-size: 1rem; letter-spacing: .11em; } .setup-secret details { grid-column: 1 / -1; } -.wrap-code { display: block; margin-top: 8px; overflow-wrap: anywhere; } +.wrap-code { display: block; margin-top: 7px; overflow-wrap: anywhere; } +.category-admin-list { display: grid; gap: 7px; margin-top: 17px; padding-top: 17px; border-top: 1px solid var(--border); } +.category-admin-list form { display: grid; grid-template-columns: auto minmax(130px, 1fr) 46px minmax(120px, .6fr) auto auto; align-items: center; gap: 8px; } +.category-admin-list input { min-height: 35px; } +.category-swatch { width: 9px; height: 32px; border-radius: 6px; background: var(--dot); box-shadow: 0 0 10px color-mix(in srgb, var(--dot), transparent 50%); } .data-actions { grid-template-columns: repeat(3, 1fr); } -.export-card { display: flex; min-height: 145px; align-items: center; gap: 16px; padding: 21px; transition: transform .18s ease, border-color .18s ease; } -.export-card:hover { transform: translateY(-3px); border-color: var(--border-strong); } +.export-card { display: flex; min-height: 140px; align-items: center; gap: 14px; padding: 19px !important; transition: transform .16s ease, border-color .16s ease; } +.export-card:hover { transform: translateY(-2px); border-color: var(--border-strong); } .export-card > div { min-width: 0; flex: 1; } -.export-card small { display: block; margin-top: 7px; } -.export-card b { font-size: 1.2rem; } -.export-icon { display: grid; width: 48px; height: 48px; flex: 0 0 48px; place-items: center; border-radius: 14px; font-weight: 900; } -.export-card.purple .export-icon { color: var(--purple-bright); background: rgba(139,92,246,.12); } -.export-card.blue .export-icon { color: var(--blue); background: rgba(46,169,255,.12); } -.export-card.pink .export-icon { color: var(--pink); background: rgba(255,79,163,.12); } +.export-card small { display: block; margin-top: 6px; font-size: .56rem; } +.export-card b { font-size: 1.1rem; } +.export-icon { display: grid; width: 45px; height: 45px; flex: 0 0 45px; place-items: center; border-radius: 12px; font-size: .68rem; font-weight: 900; } +.export-card.purple .export-icon { color: var(--purple-light); background: rgba(139, 92, 246, .1); } +.export-card.blue .export-icon { color: var(--blue); background: rgba(73, 168, 255, .1); } +.export-card.pink .export-icon { color: var(--pink); background: rgba(255, 91, 157, .1); } .data-grid { grid-template-columns: repeat(2, 1fr); } -.file-drop { padding: 24px; border: 1px dashed var(--border-strong); border-radius: 13px; text-align: center; cursor: pointer; } -.file-drop input { margin-bottom: 9px; background: transparent; } -.file-drop span { display: block; font-size: .62rem; } -.database-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } -.database-facts div { padding: 13px; border: 1px solid var(--border); border-radius: 10px; } +.file-drop { padding: 22px; border: 1px dashed var(--border-strong); border-radius: 11px; text-align: center; cursor: pointer; } +.file-drop input { margin-bottom: 8px; background: transparent; } +.file-drop span { display: block; font-size: .57rem; } +.database-facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; } +.database-facts div { padding: 12px; border: 1px solid var(--border); border-radius: 9px; } .database-facts span, .database-facts strong { display: block; } -.database-facts span { color: var(--muted); font-size: .58rem; } -.database-facts strong { margin-top: 4px; font-size: .7rem; } -.portability-note { display: flex; align-items: center; gap: 14px; } -.portability-note p { margin: 4px 0 0; color: var(--muted); font-size: .7rem; } +.database-facts span { color: var(--muted); font-size: .52rem; } +.database-facts strong { margin-top: 3px; font-size: .65rem; } +.portability-note { display: flex; align-items: center; gap: 13px; } +.portability-note p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; } -.auth-body { min-height: 100vh; overflow-x: hidden; background: #080a12; color: #f4f5fb; } -.auth-shell { display: grid; min-height: 100vh; grid-template-columns: 1.15fr .85fr; } -.auth-story { position: relative; display: flex; min-height: 100vh; flex-direction: column; justify-content: space-between; overflow: hidden; padding: clamp(30px, 6vw, 78px); background: radial-gradient(circle at 60% 45%, rgba(139,92,246,.18), transparent 27rem), linear-gradient(145deg, #111426, #090b14); } -.auth-story::before { position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px); background-size: 42px 42px; content: ""; mask-image: linear-gradient(to bottom right, black, transparent 75%); } +.auth-body { min-height: 100vh; overflow-x: hidden; background: #080a12; color: #f6f7fb; } +.auth-shell { display: grid; min-height: 100vh; grid-template-columns: 1.08fr .92fr; } +.auth-story { position: relative; display: flex; min-height: 100vh; flex-direction: column; justify-content: space-between; overflow: hidden; padding: clamp(30px, 5vw, 68px); background: linear-gradient(90deg, rgba(8, 10, 18, .9), rgba(8, 10, 18, .28)), url("/assets/images/financial-flow.png") center / cover no-repeat; } +.auth-story::before { position: absolute; inset: 0; background: linear-gradient(145deg, rgba(139, 92, 246, .05), transparent 55%); content: ""; } .auth-story > * { position: relative; z-index: 1; } -.auth-brand { padding: 0; } -.auth-story h1 { max-width: 650px; margin-bottom: 20px; font-size: clamp(3rem, 6vw, 6.5rem); line-height: .92; letter-spacing: -.07em; } -.auth-story > div:nth-child(2) > p:last-child { max-width: 580px; color: #9da3b8; font-size: 1rem; } -.auth-orbit { position: absolute; width: 340px; height: 340px; right: -55px; bottom: -65px; border: 1px solid rgba(169,120,255,.18); border-radius: 50%; } -.auth-orbit span { position: absolute; inset: 35px; border: 1px solid rgba(46,169,255,.18); border-radius: 50%; } -.auth-orbit .orbit-two { inset: 80px; border-color: rgba(255,79,163,.18); } -.auth-orbit .orbit-three { inset: 123px; border-color: rgba(67,245,164,.18); } -.auth-orbit strong { position: absolute; inset: 0; display: grid; place-items: center; color: var(--green); font-size: 3rem; text-shadow: 0 0 30px rgba(67,245,164,.4); } -.auth-panel { display: grid; place-items: center; padding: 35px; background: #0c0e19; } -.auth-panel .flash { width: min(430px, 100%); margin: 0 0 14px; } -.auth-card { width: min(430px, 100%); padding: 32px; border: 1px solid rgba(255,255,255,.09); border-radius: 22px; background: #131627; box-shadow: 0 30px 80px rgba(0,0,0,.35); } -.auth-card h2 { margin-bottom: 8px; font-size: 1.7rem; } -.auth-card > .muted { margin-bottom: 24px; } -.auth-card input, .auth-card select { background: #191d32; } -.otp-input { font-size: 1.6rem; letter-spacing: .4em; text-align: center; } +.auth-story h1 { max-width: 620px; margin-bottom: 18px; font-size: clamp(2.8rem, 5.5vw, 5.8rem); line-height: .93; letter-spacing: -.07em; } +.auth-story > div:nth-child(2) > p:last-child { max-width: 560px; color: #a5adbe; font-size: .9rem; } +.auth-orbit { position: absolute; width: 320px; height: 320px; right: -60px; bottom: -75px; border: 1px solid rgba(169, 139, 255, .14); border-radius: 50%; } +.auth-orbit span { position: absolute; inset: 35px; border: 1px solid rgba(73, 168, 255, .14); border-radius: 50%; } +.auth-orbit .orbit-two { inset: 78px; border-color: rgba(255, 91, 157, .14); } +.auth-orbit .orbit-three { inset: 120px; border-color: rgba(73, 232, 164, .14); } +.auth-orbit strong { position: absolute; inset: 0; display: grid; place-items: center; color: var(--green); font-size: 2.6rem; text-shadow: 0 0 28px rgba(73, 232, 164, .35); } +.auth-panel { display: grid; place-items: center; padding: 32px; background: #0b0e16; } +.auth-panel .flash { width: min(420px, 100%); margin: 0 0 13px; } +.auth-card { width: min(420px, 100%); padding: 30px; border: 1px solid rgba(255, 255, 255, .08); border-radius: 19px; background: #121621; box-shadow: 0 28px 75px rgba(0, 0, 0, .32); } +.auth-card h2 { margin-bottom: 7px; font-size: 1.55rem; } +.auth-card > .muted { margin-bottom: 22px; } +.auth-card input, .auth-card select { background: #191e2c; } +.otp-input { font-size: 1.45rem; letter-spacing: .38em; text-align: center; } -@media (max-width: 1180px) { - .hero-grid { grid-template-columns: repeat(3, 1fr); } - .hero-balance { grid-column: 1 / -1; } - .comparison-metrics { grid-template-columns: repeat(2, 1fr); } +@media (max-width: 1220px) { + .metric-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-layout { grid-template-columns: minmax(0, 1.55fr) minmax(290px, .85fr); } + .budget-row { grid-template-columns: minmax(160px, .75fr) minmax(180px, 1.1fr) auto; gap: 13px; } + .hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .data-actions { grid-template-columns: 1fr; } .settings-grid { grid-template-columns: 1fr; } .settings-grid > .span-2 { grid-column: 1; } .settings-nav { position: static; display: flex; overflow-x: auto; } } -@media (max-width: 900px) { - .sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 20px 0 70px rgba(0,0,0,.4); } +@media (max-width: 980px) { + .sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 20px 0 70px rgba(0, 0, 0, .42); } .sidebar.open { transform: translateX(0); } + .sidebar-scrim { position: fixed; inset: 0; z-index: 45; border: 0; background: rgba(2, 4, 8, .6); backdrop-filter: blur(2px); } + .sidebar.open + .sidebar-scrim { display: block; } .main { margin-left: 0; } - .menu-button { display: block; } - .content-grid, .comparison-grid, .dead-grid, .data-grid { grid-template-columns: 1fr; } + .menu-button { display: grid; } + .dashboard-layout { grid-template-columns: minmax(0, 1fr); } + .dashboard-side { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-side > * { min-width: 0; } + .usage-card { grid-column: 1 / -1; } + .comparison-grid, .content-grid, .dead-grid, .data-grid { grid-template-columns: minmax(0, 1fr); } .content-grid > .span-2 { grid-column: 1; } - .calendar-day { min-height: 110px; } .auth-shell { grid-template-columns: 1fr; } .auth-story { display: none; } .auth-panel { min-height: 100vh; } } -@media (max-width: 650px) { - .topbar { min-height: 78px; padding: 15px 16px; } +@media (max-width: 760px) { + .topbar { min-height: 72px; gap: 10px; padding: 13px 15px; } .topbar .eyebrow { display: none; } - .month-switcher input { width: 118px; } - .month-switcher a { width: 25px; } - .page-content { padding: 20px 15px 45px; } - .flash { margin-left: 15px; margin-right: 15px; } - .hero-grid, .comparison-metrics { grid-template-columns: 1fr; } - .hero-balance { grid-column: 1; } - .metric-card { min-height: 135px; } - .summary-strip { grid-template-columns: 1fr 1fr; } - .summary-strip div { border-right: 0; } - .summary-strip .button { grid-column: 1 / -1; } + .top-action { display: none; } + .month-switcher input { width: 112px; } + .page-content { padding: 18px 14px 40px; } + .flash { margin-left: 14px; margin-right: 14px; } + .app-footer { margin: 0 14px; } + .overview-hero { min-height: 390px; grid-template-columns: 1fr; align-content: end; } + .hero-art { background: linear-gradient(0deg, rgba(8, 12, 20, .99) 0%, rgba(8, 12, 20, .8) 45%, rgba(8, 12, 20, .15) 100%), url("/assets/images/financial-flow.png") 70% center / cover no-repeat; } + .hero-copy { padding: 25px 23px 15px; } + .hero-label { margin-top: 18px; } + .hero-gauge { justify-content: flex-start; padding: 10px 23px 24px; } + .gauge-ring { width: 86px; height: 86px; flex-basis: 86px; background: radial-gradient(circle 33px, #111522 97%, transparent 100%), conic-gradient(var(--green) var(--angle), rgba(255, 255, 255, .09) 0); } + .gauge-ring span { font-size: 1rem; } + .budget-row { grid-template-columns: 1fr auto; } + .budget-progress { grid-column: 1 / -1; grid-row: 2; } + .row-actions { grid-column: 2; grid-row: 1; } + .metric-grid, .summary-grid, .hero-grid { grid-template-columns: minmax(0, 1fr); } + .dashboard-side { grid-template-columns: minmax(0, 1fr); } + .usage-card { grid-column: 1; } + .page-intro { align-items: flex-start; flex-direction: column; } .card-heading { flex-wrap: wrap; } .search-box { width: 100%; } .form-grid, .toggle-grid { grid-template-columns: 1fr; } .form-grid .span-2 { grid-column: 1; } .inline-fields { grid-template-columns: 1fr; } - .calendar-weekdays span { padding: 8px 2px; font-size: .52rem; } - .calendar-day { min-height: 76px; padding: 4px; } - .calendar-event { padding: 3px; border-left-width: 1px; } - .calendar-event > span, .calendar-event small, .calendar-event form { display: none; } - .calendar-event strong { font-size: .48rem; } - .day-number { width: 20px; height: 20px; } - .dead-hero { align-items: stretch; flex-direction: column; padding: 22px; } + .category-admin-list form { grid-template-columns: auto 1fr 44px auto auto; } + .category-admin-list .money-input { grid-column: 2 / 4; } + .dead-hero { align-items: stretch; flex-direction: column; padding: 23px !important; } .visible-balance, .concealed-balance { width: 100%; min-width: 0; } .setup-secret { grid-template-columns: 1fr; } .setup-secret details { grid-column: 1; } .setup-secret > code { overflow-x: auto; } .database-facts { grid-template-columns: 1fr; } - table.responsive, table.responsive tbody, table.responsive tr, table.responsive td { display: block; } - .auth-panel { padding: 18px; } - .auth-card { padding: 24px; } + .data-table thead { display: none; } + .data-table, .data-table tbody, .data-table tr, .data-table td { display: block; width: 100%; } + .data-table tr { position: relative; padding: 13px 0; border-bottom: 1px solid var(--border); } + .data-table tr:last-child { border-bottom: 0; } + .data-table td { display: grid; grid-template-columns: 105px 1fr; gap: 10px; padding: 5px 0; border: 0; } + .data-table td::before { color: var(--muted); font-size: .55rem; font-weight: 800; text-transform: uppercase; content: attr(data-label); } + .data-table td.table-actions { display: flex; position: absolute; top: 10px; right: 0; width: auto; } + .data-table td.table-actions::before { display: none; } +} + +@media (max-width: 520px) { + .metric-grid, .summary-grid, .hero-grid { grid-template-columns: minmax(0, 1fr); } + .metric-card { min-height: 130px; } + .hero-actions { align-items: stretch; flex-direction: column; } + .hero-actions .button { width: 100%; } + .hero-gauge { align-items: flex-start; } + .dialog-card { padding: 18px; } + .dialog-actions { flex-direction: column-reverse; } + .dialog-actions .button { width: 100%; } + .settings-actions { align-items: stretch; flex-direction: column; } + .settings-actions .button { width: 100%; } + .category-admin-list form { grid-template-columns: auto 1fr auto auto; } + .category-admin-list input[type="color"] { grid-column: 2; grid-row: 2; } + .category-admin-list .money-input { grid-column: 2 / -1; } + .auth-panel { padding: 17px; } + .auth-card { padding: 23px; } } diff --git a/public/assets/app.js b/public/assets/app.js index 14369c5..1b8d6fc 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -13,9 +13,11 @@ document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart); }); - document.querySelector('[data-menu-toggle]')?.addEventListener('click', () => { - document.querySelector('#sidebar')?.classList.toggle('open'); - }); + const sidebar = document.querySelector('#sidebar'); + const closeMenu = () => sidebar?.classList.remove('open'); + document.querySelector('[data-menu-toggle]')?.addEventListener('click', () => sidebar?.classList.toggle('open')); + document.querySelector('[data-menu-close]')?.addEventListener('click', closeMenu); + sidebar?.querySelectorAll('a').forEach((link) => link.addEventListener('click', closeMenu)); document.querySelector('[data-month-picker]')?.addEventListener('change', (event) => { const picker = event.currentTarget; @@ -34,12 +36,19 @@ if (event.target === dialog) dialog.close(); }); }); + const requestedDialog = new URLSearchParams(window.location.search).get('open'); + if (requestedDialog) document.getElementById(requestedDialog)?.showModal(); document.querySelectorAll('[data-confirm]').forEach((form) => { form.addEventListener('submit', (event) => { if (!window.confirm(form.dataset.confirm)) event.preventDefault(); }); }); + document.querySelectorAll('[data-confirm-button]').forEach((button) => { + button.addEventListener('click', (event) => { + if (!window.confirm(button.dataset.confirmButton)) event.preventDefault(); + }); + }); document.querySelectorAll('.flash button').forEach((button) => { button.addEventListener('click', () => button.closest('.flash')?.remove()); @@ -71,6 +80,21 @@ }); } + document.querySelectorAll('[data-dead-transfer-form]').forEach((form) => { + const direction = form.querySelector('[name="direction"]'); + const restoreFields = form.querySelector('[data-restore-fields]'); + const restoreType = form.querySelector('[data-restore-type]'); + const restoreItem = form.querySelector('[data-restore-item]'); + const syncRestoreFields = () => { + const restoring = direction?.value === 'restore'; + if (restoreFields) restoreFields.hidden = !restoring; + if (restoreItem) restoreItem.hidden = !restoring || restoreType?.value !== 'item'; + }; + direction?.addEventListener('change', syncRestoreFields); + restoreType?.addEventListener('change', syncRestoreFields); + syncRestoreFields(); + }); + function drawTrendChart(canvas) { let data; try { @@ -91,7 +115,7 @@ const styles = getComputedStyle(document.documentElement); const text = styles.getPropertyValue('--muted').trim(); const border = styles.getPropertyValue('--border-strong').trim(); - const purple = styles.getPropertyValue('--purple-bright').trim(); + const purple = styles.getPropertyValue('--purple-light').trim(); const pink = styles.getPropertyValue('--pink').trim(); const padding = { top: 20, right: 12, bottom: 32, left: 48 }; const plotWidth = width - padding.left - padding.right; diff --git a/public/assets/images/financial-flow.png b/public/assets/images/financial-flow.png new file mode 100644 index 0000000..556a583 Binary files /dev/null and b/public/assets/images/financial-flow.png differ diff --git a/public/index.php b/public/index.php index cae6f11..b94d985 100644 --- a/public/index.php +++ b/public/index.php @@ -10,6 +10,10 @@ use Budget\Totp; require __DIR__ . '/../app/bootstrap.php'; +if (!class_exists('Support', false)) { + class_alias(Support::class, 'Support'); +} + function h(mixed $value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); @@ -25,6 +29,32 @@ function csrf_field(): string return ''; } +function icon(string $name, string $class = ''): string +{ + $paths = [ + 'home' => '', + 'expense' => '', + 'calendar' => '', + 'chart' => '', + 'vault' => '', + 'settings' => '', + 'data' => '', + 'plus' => '', + 'wallet' => '', + 'bell' => '', + 'edit' => '', + 'trash' => '', + 'search' => '', + 'menu' => '', + 'close' => '', + 'sun' => '', + 'logout' => '', + 'clock' => '', + ]; + $body = $paths[$name] ?? $paths['home']; + return ''; +} + function redirect_to(string $route, array $parameters = []): never { header('Location: ' . url($route, $parameters)); @@ -135,6 +165,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { redirect_to('dashboard', ['month' => $month]); } + if ($action === 'income.update') { + $budget->updateIncome( + (int) ($_POST['id'] ?? 0), + $month, + (string) ($_POST['label'] ?? ''), + (float) ($_POST['amount'] ?? 0), + (string) ($_POST['received_on'] ?? date('Y-m-d')), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'income.updated', 'income', (int) ($_POST['id'] ?? 0), ['month' => $month]); + Support::flash('success', 'Income entry updated.'); + redirect_to('dashboard', ['month' => $month]); + } + if ($action === 'income.delete') { $budget->deleteIncome((int) ($_POST['id'] ?? 0)); Support::audit($pdo, $auth->id(), 'income.deleted', 'income', (int) ($_POST['id'] ?? 0)); @@ -150,7 +194,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ); Support::audit($pdo, $auth->id(), 'category.created', 'category', $id); Support::flash('success', 'Category created.'); - redirect_to('dashboard', ['month' => $month]); + $returnRoute = (string) ($_POST['return_route'] ?? 'dashboard'); + redirect_to(in_array($returnRoute, ['dashboard', 'settings'], true) ? $returnRoute : 'dashboard', $returnRoute === 'dashboard' ? ['month' => $month] : []); + } + + if ($action === 'category.update') { + $budget->updateCategory( + (int) ($_POST['id'] ?? 0), + (string) ($_POST['name'] ?? ''), + (string) ($_POST['color'] ?? '#7c5cff'), + (float) ($_POST['monthly_limit'] ?? 0) + ); + Support::audit($pdo, $auth->id(), 'category.updated', 'category', (int) ($_POST['id'] ?? 0)); + Support::flash('success', 'Category updated.'); + redirect_to('settings'); + } + + if ($action === 'category.archive') { + $budget->archiveCategory((int) ($_POST['id'] ?? 0)); + Support::audit($pdo, $auth->id(), 'category.archived', 'category', (int) ($_POST['id'] ?? 0)); + Support::flash('success', 'Category archived. Historical records were preserved.'); + redirect_to('settings'); } if ($action === 'item.add') { @@ -175,6 +239,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { redirect_to('dashboard', ['month' => $month]); } + if ($action === 'item.update') { + $budget->updateBudgetItem( + (int) ($_POST['id'] ?? 0), + $month, + !empty($_POST['category_id']) ? (int) $_POST['category_id'] : null, + (string) ($_POST['name'] ?? ''), + (float) ($_POST['planned_amount'] ?? 0), + !empty($_POST['due_date']) ? (string) $_POST['due_date'] : null, + (string) ($_POST['recurrence'] ?? 'none'), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'budget_item.updated', 'budget_item', (int) ($_POST['id'] ?? 0), ['month' => $month]); + Support::flash('success', 'Budget item updated.'); + redirect_to('dashboard', ['month' => $month]); + } + if ($action === 'transaction.add') { $id = $budget->addTransaction( $month, @@ -197,12 +277,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { redirect_to('transactions', ['month' => $month]); } + if ($action === 'transaction.update') { + $budget->updateTransaction( + (int) ($_POST['id'] ?? 0), + $month, + !empty($_POST['budget_item_id']) ? (int) $_POST['budget_item_id'] : null, + !empty($_POST['category_id']) ? (int) $_POST['category_id'] : null, + (string) ($_POST['merchant'] ?? ''), + (float) ($_POST['amount'] ?? 0), + (string) ($_POST['transacted_on'] ?? date('Y-m-d')), + (string) ($_POST['notes'] ?? '') + ); + Support::audit($pdo, $auth->id(), 'transaction.updated', 'transaction', (int) ($_POST['id'] ?? 0), ['month' => $month]); + Support::flash('success', 'Expense updated and budget progress recalculated.'); + redirect_to('transactions', ['month' => $month]); + } + if ($action === 'dead.move') { $id = $budget->moveDeadAccount( $month, (string) ($_POST['direction'] ?? 'deposit'), (float) ($_POST['amount'] ?? 0), - (string) ($_POST['notes'] ?? '') + (string) ($_POST['notes'] ?? ''), + (string) ($_POST['destination_type'] ?? 'income'), + !empty($_POST['destination_id']) ? (int) $_POST['destination_id'] : null ); Support::audit($pdo, $auth->id(), 'dead_account.moved', 'dead_entry', $id, ['month' => $month]); Support::flash('success', 'Dormant account transfer recorded.'); @@ -230,18 +328,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if ($action === 'settings.save') { + $postSetting = static function (string $key, mixed $default = ''): mixed { + if (array_key_exists($key, $_POST)) { + return $_POST[$key]; + } + return $_POST[str_replace('.', '_', $key)] ?? $default; + }; $stringSettings = [ 'app.name', 'app.currency_symbol', 'app.timezone', 'theme.default', 'dead.income_mode', 'dead.transaction_mode', 'alerts.email', 'mailgun.domain', 'mailgun.region', 'mailgun.from', ]; foreach ($stringSettings as $key) { - if (array_key_exists($key, $_POST)) { - $settings->set($key, trim((string) $_POST[$key])); - } + $settings->set($key, trim((string) $postSetting($key))); } foreach (['dead.income_value', 'dead.transaction_value'] as $key) { - $settings->set($key, max(0, (float) ($_POST[$key] ?? 0))); + $settings->set($key, max(0, (float) $postSetting($key, 0))); } foreach (['income', 'bills', 'calendar', 'dead_account', 'comparisons', 'alerts', 'import_export'] as $module) { $settings->set('modules.' . $module, isset($_POST['modules_' . $module])); @@ -249,13 +351,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $settings->set('dead.hide_balance', isset($_POST['dead_hide_balance'])); $windows = array_values(array_unique(array_filter(array_map( static fn (string $value): int => max(0, (int) trim($value)), - explode(',', (string) ($_POST['alerts.windows'] ?? '7,3,1')) + explode(',', (string) $postSetting('alerts.windows', '7,3,1')) ), static fn (int $value): bool => $value <= 365))); - $times = array_values(array_filter(array_map('trim', explode(',', (string) ($_POST['alerts.times'] ?? '09:00'))), static fn (string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time))); + $times = array_values(array_filter(array_map('trim', explode(',', (string) $postSetting('alerts.times', '09:00'))), static fn (string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time))); $settings->set('alerts.windows', $windows ?: [7, 3, 1]); $settings->set('alerts.times', $times ?: ['09:00']); - if (trim((string) ($_POST['mailgun.api_key'] ?? '')) !== '') { - $settings->set('mailgun.api_key', trim((string) $_POST['mailgun.api_key'])); + if (trim((string) $postSetting('mailgun.api_key')) !== '') { + $settings->set('mailgun.api_key', trim((string) $postSetting('mailgun.api_key'))); } elseif (isset($_POST['clear_mailgun_key'])) { $settings->set('mailgun.api_key', ''); } @@ -416,6 +518,7 @@ switch ($route) { 'month' => $month, 'balance' => $budget->deadBalance(), 'entries' => $budget->deadEntries(null, 150), + 'items' => $budget->budgetItems($month), 'hideBalance' => (bool) $settings->get('dead.hide_balance', true), 'symbol' => $symbol, ]); @@ -432,6 +535,7 @@ switch ($route) { 'newSecret' => $newSecret, 'totpUri' => $newSecret ? Totp::uri($newSecret, (string) $user['email'], (string) $settings->get('app.name', 'Neon Ledger')) : null, 'mailgunConfigured' => (new Mailgun($settings))->configured(), + 'categories' => $budget->categories(), ]); case 'data': render_view('data', [ @@ -454,6 +558,9 @@ switch ($route) { 'categories' => $budget->categories(), 'categoryStats' => $budget->categoryStats($month), 'comparison' => $budget->comparison($month, 6), + 'previousSummary' => $budget->summary(date('Y-m', strtotime($month . '-01 -1 month')), false), + 'upcomingBills' => $budget->upcomingBills($month), + 'recentActivity' => $budget->recentActivity($month), 'visitorStats' => $budget->visitorStats(), 'deadBalance' => $budget->deadBalance(), 'symbol' => $symbol, diff --git a/scripts/self-test.php b/scripts/self-test.php new file mode 100644 index 0000000..033fbc5 --- /dev/null +++ b/scripts/self-test.php @@ -0,0 +1,60 @@ +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"; diff --git a/views/auth-layout.php b/views/auth-layout.php index 2523e91..75848da 100644 --- a/views/auth-layout.php +++ b/views/auth-layout.php @@ -6,7 +6,7 @@ <?= h($title ?? 'Welcome') ?> · <?= h($appName) ?> - +
diff --git a/views/comparisons.php b/views/comparisons.php index 93fb19d..ee2747f 100644 --- a/views/comparisons.php +++ b/views/comparisons.php @@ -2,16 +2,24 @@ $totals = $yearly['totals']; $bestMonth = null; 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']) { $bestMonth = $row; } } ?>
-
Σ

resources

Across all twelve months
-

expenses

0 ? number_format($totals['expenses'] / $totals['gross_resources'] * 100, 1) : '0.0' ?>% of resources
-

Moved dormant

Net dormant deposits
-

Strongest remainder

+

fresh income

Rollover excluded to avoid double-counting
+

expenses

0 ? number_format($totals['expenses'] / $totals['fresh_income'] * 100, 1) : '0.0' ?>% of fresh income
+

Moved dormant

restored
+

Strongest remainder

@@ -30,7 +38,7 @@ foreach ($yearly['months'] as $row) {

- 0): ?>% of category limit + 0): ?>% of planned 0): ?>% of category limitNo target defined
@@ -41,19 +49,20 @@ foreach ($yearly['months'] as $row) {

Statistical table

monthly breakdown

- +
- + - + + - + diff --git a/views/dashboard.php b/views/dashboard.php index 29d832b..76476fc 100644 --- a/views/dashboard.php +++ b/views/dashboard.php @@ -1,144 +1,274 @@ = 0 ? 'green' : 'red'; -$progress = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['expenses'] / $summary['gross_resources'] * 100)) : 0; +$monthLabel = date('F Y', strtotime($month . '-01')); +$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); ?> -
-
-
-

remaining

-
-

% of available resources spent

+
+ +
+
+ + plan + +
-
- %left +

Spendable balance

+
+

+ available + · + % remains +

+
+ Record expense +
+
+
+
+ % + remaining +
+
+ Month forecast + + +
+
+
+ +
+
+
Fresh funds
+

Income this month

+ + starting + added
-

Gross resources

- start + rollover +
% used
+

Total spent

+ + target · planned
-

Expenses

- defined budget item +
% rate
+

Moved dormant

+ + restored this month
-

Dormant balance

- - % of this month routed away +
= 0 ? '+' : '' ?>
+

Previous remainder

+ + Compared with ’s closing balance
-
-
-
+
+
+
-

Progressional tracking

Budget targets

- +

Progressional plan

Budget targets

Payments reduce the amount still owing in real time.

+
-
No targets yet

Add a bill or category target to begin progress tracking.

+
Build your first target

Add rent, internet, groceries, or any amount you want to track.

- -
-
- - -
-
-
- paid - owing -
+
+ +
· due
+
+
+
% paid left
+
+ of +
+
+ +
+ + +
- -
- - -
-
+
-

Comparative view

Six-month movement

- Full analysis → +

Comparative movement

Six-month cash flow

Resources include rollover for each individual month.

+ Open analytics
- -
-
- -
-
-

Monthly inputs

Starting point

-
- - - - - - - -
- - - +
- module('income')): ?> -
-

Income stream

Additional income

- -

No additional income recorded.

+
+
+

Cash activity

Recent movement

+ All transactions +
+ +
No movement yet

Income, expenses, and dormant transfers will collect here.

-
- -
· withheld+
+
+ +
+ +
·
+ = 0 ? '+' : '-' ?> +
+
+ +
+
+ +
+ +

Monthly foundation

Edit plan

+
+ + + + +
+
+ +
+
-

Define the target

Add budget item

-
- - - - - - -
- +

Progressional target

Add budget item

+ +
+ + +
+ +

Maintain target

Edit

+ +

Changes apply to this month. Future recurring copies use the latest saved version.

+
+ +
+ +
-

Beyond the starting point

Add income

-
- - - - -
-

The configured income withholding will move automatically to the dormant account.

- +

Beyond the starting point

Add income

+ +

The configured withholding rule is applied automatically.

+
+ + + +
+ +

Maintain income

Edit

+ +
+ + + +
+ +
+ diff --git a/views/dead-account.php b/views/dead-account.php index 34ca7d0..7c2ccea 100644 --- a/views/dead-account.php +++ b/views/dead-account.php @@ -1,8 +1,9 @@ -
+
+

Dormant and disregarded

-

Dead account balance

-

Money here is excluded from usable monthly funds until you explicitly restore it.

+

Your out-of-use reserve

+

Money here is subtracted from spendable funds, remains fully traceable, and can later be restored to monthly income or earmarked for a target.

@@ -27,9 +32,9 @@

Automatic rules

How funds arrive here

Configure →
-

Income withholdingget('dead.income_value', 0)) ?>get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source

-

Transaction pullget('dead.transaction_value', 0)) ?>get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded

-

RestorationsReturn to the selected month without changing historical expense records

+

Income withholdingget('dead.income_value', 0)) ?>get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source

+

Transaction pullget('dead.transaction_value', 0)) ?>get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded

+

RestorationsReturn to monthly income or identify a specific target without rewriting history

@@ -37,7 +42,7 @@

Permanent paper trail

Dormant ledger

- +
The account is empty

Automatic or manual transfers will appear here.

@@ -51,7 +56,7 @@
- + diff --git a/views/layout.php b/views/layout.php index 5cc792a..4c5cfd7 100644 --- a/views/layout.php +++ b/views/layout.php @@ -5,6 +5,14 @@ $previousMonth = 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'); $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')], +]; ?> @@ -12,77 +20,97 @@ $defaultTheme = (string) $settings->get('theme.default', 'dark'); + <?= h($title ?? 'Planner') ?> · <?= h($appName) ?> - +
+
- -
-

+ +
+

- + - +
+ Add expense
- - +

+
+
Private by default · SQLite powered
- + diff --git a/views/partials/income-fields.php b/views/partials/income-fields.php new file mode 100644 index 0000000..15be63b --- /dev/null +++ b/views/partials/income-fields.php @@ -0,0 +1,7 @@ + +
+ + + + +
diff --git a/views/partials/item-fields.php b/views/partials/item-fields.php new file mode 100644 index 0000000..6f9bb7b --- /dev/null +++ b/views/partials/item-fields.php @@ -0,0 +1,9 @@ + +
+ + + + + + +
diff --git a/views/partials/transaction-fields.php b/views/partials/transaction-fields.php new file mode 100644 index 0000000..b4ee2c2 --- /dev/null +++ b/views/partials/transaction-fields.php @@ -0,0 +1,9 @@ + +
+ + + + + + +
diff --git a/views/settings.php b/views/settings.php index d904630..872fac4 100644 --- a/views/settings.php +++ b/views/settings.php @@ -96,12 +96,27 @@
-

Organize spending

Add an expense category

+

Organize spending

Expense categories

Searchable records use these names, colors, and optional monthly guardrails.

- + + +
+ +
+ + + + +
+ + + + +
+
diff --git a/views/transactions.php b/views/transactions.php index abeeabb..7ed0325 100644 --- a/views/transactions.php +++ b/views/transactions.php @@ -1,33 +1,39 @@ -
-
Available this month
-
Total spent
-
Remaining
- +
+

Searchable expense ledger

Every payment, one trail

Link a payment to a target and its owing balance moves immediately.

+
-
+
+
Available
+
Spent
+
Set aside
+
Remaining
+
+ +
-

Searchable ledger

Expense history

- +

Transaction history

recorded expense

+
-
No expenses recorded

Record a payment to move a bill’s progress forward.

+
No expenses recorded

Record a payment to start progressing this month’s targets.

-
MonthResourcesPlannedExpensesDormantRemainder
MonthFresh incomeRolloverPlannedExpensesSavedRemainder
Target: Monthly income = 0 ? '+' : '' ?>
- +
DateDescriptionCategory / itemAmountDormant pull
+ - + - - + + - @@ -41,16 +47,20 @@
-

Progress a target

Record expense

-
- - - - - - -
-

A configured per-transaction amount or percentage will also be moved to the dormant account.

- +

Progress a target

Record expense

+ +

Your configured per-transaction dormant rule is applied automatically.

+
+ + + +
+ +

Maintain transaction

Edit

+ +
+ +
+
DateDescriptionCategory / targetAmountDormant pullActions
- + +
- +