Commit Graph

9 Commits

Author SHA1 Message Date
Víctor Falcón 1cc10566a3
feat: parent/child category tree (#474)
## Summary

Adds nested categories (parent → child, up to **3 levels**) across the
app. Children inherit their parent's type and cashflow direction, and
every category selector now renders the hierarchy as an indented tree.
Gated behind the `CategoryTree` Pennant flag (off by default).

## Backend

- **Migration**: nullable self-referencing `parent_id`; uniqueness
scoped per-parent via a `parent_unique_marker` virtual column (root
names stay unique). New composite unique created before dropping the old
one so the `user_id` FK keeps a supporting index.
- **`CategoryTree` service**: descendant/ancestor resolution, depth &
cycle checks, type cascade, subtree deletion.
- **Validation**: depth limit, cycle prevention, inherited + locked
child type.
- **Delete strategies**: reparent (default), promote to root, or cascade
(uncategorizes affected transactions).
- **Transaction filter** expands a selected parent to its descendants.
- **Cashflow Sankey & breakdown** roll up to top-level parents with
click-to-drill (children + a parent "direct" node).
- **Budgets** tracking a parent also count their children's
transactions.
- **Unified** the frontend category query behind
`Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every
selector receives the full Category shape, including `parent_id`.

## Frontend

- New `category-tree.ts` helpers (build/flatten/descendants/path,
tree-aware selection toggle + tri-state).
- **Settings page**: indented tree, sortable by name/color/type
(siblings sorted, hierarchy preserved), parent picker, delete-strategy
dialog.
- **Combobox** (transaction table cell + edit/create modal + parent
picker): indented tree, search keeps matches with their ancestors.
- **Transaction filter**: indented tree, tri-state cascading selection
(parent ↔ children), themed checkboxes, selected branches float to top
on open, ancestor-aware search.
- **Categorizer palette** and **budget multi-select**: same indented
tree + ancestor-aware search.
- **Sankey**: click a parent node to drill into its children, with
breadcrumb.
- Pennant `CategoryTree` flag gates the parent UI.

## Tests

- Pest: model/validation, delete strategies, filter expansion, cashflow
rollup/drill, budget child inclusion.
- Vitest: tree-aware selection logic.
- All green; Pint / ESLint / Prettier clean.

## Rollout

Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
2026-06-03 19:30:12 +02:00
Víctor Falcón 6caadad1db
fix: net category refunds in cashflow (#441)
## Summary
- net mixed-sign category transactions before cashflow analytics display
- align dashboard, cashflow trend, breakdown, and sankey behavior with
top categories
- add feature tests for refund-style expense netting

## Tests
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
tests/Feature/DashboardAnalyticsTest.php
- vendor/bin/pint --dirty --format agent
2026-05-27 15:16:50 +02:00
Víctor Falcón 718cfa9e8f
Fix Sentry transaction and dashboard crashes (#372)
## Summary
- guard transaction list Inertia callbacks against non-paginated
transaction props
- exclude soft-deleted categories from dashboard category aggregates
- add dashboard category and cursor pagination tests

Fixes PHP-LARAVEL-1K
Fixes PHP-LARAVEL-1J

## Tests
- npm run test -- cursor-pagination.test.ts
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
--filter='top categories'
- vendor/bin/pint --dirty --format agent

Note: npm run types still fails on existing unrelated TypeScript errors.
2026-05-10 11:10:31 +01:00
Víctor Falcón 752176e80d feat(dashboard): merge real estate accounts with linked mortgages on dashboard
Extend the dashboard to hide linked loan accounts and display merged
real estate cards showing equity as the primary balance, a dual-line
sparkline (solid market value, dashed mortgage owed), 'Mortgage at'
subtitle with bank logo, and a rich tooltip with Market Value,
Mortgage Owed, and color-coded Equity.
2026-03-26 21:04:13 +01:00
Víctor Falcón b661255d09
refactor: extract AccountMetricsService to deduplicate balance computation logic (#147)
## Why

### Problem
Account balance metrics computation (12-month sparklines, net worth
evolution, currency conversion) was duplicated across three controllers:
- `AccountController` — account index page metrics
- `DashboardController` — dashboard net worth evolution
- `DashboardAnalyticsController` — analytics API net worth evolution
(monthly + daily)

Each had its own `convertBalance()` method, its own BalanceLookup
iteration loop, and its own invested amount handling. Additionally,
`AccountController::show()` re-queried `categories`, `accounts`, and
`banks` — all already available as shared Inertia props from
`HandleInertiaRequests` middleware.

## What

### Changes
- **Extract `AccountMetricsService`** with three public methods:
- `getAccountMetrics()` — per-account balance metrics with sparkline
history (used by accounts index)
- `getNetWorthEvolution()` — monthly net worth evolution with
per-account balances (used by dashboard + analytics API)
- `getNetWorthDailyEvolution()` — daily net worth evolution (used by
analytics API)
- **Refactor `AccountController`** — delegate to
`AccountMetricsService`, remove 3 private methods (`getAccountMetrics`,
`formatMonth`, `convertBalance`)
- **Refactor `DashboardController`** — delegate to
`AccountMetricsService`, remove `getNetWorthEvolution` loop and
`convertBalance`
- **Refactor `DashboardAnalyticsController`** — delegate to
`AccountMetricsService` for `netWorthEvolution` and
`netWorthDailyEvolution`, remove `convertBalance`, `getBalanceAt`,
`getInvestedAmountAt` private methods
- **Remove duplicate queries from `AccountController::show()`** —
`categories`, `accounts`, `banks` are already shared by middleware
- **Add missing `encrypted` column** to the middleware's shared
`accounts` query so components that need it (EditAccountDialog, etc.)
receive it

### Impact
- **Net -87 lines** across 5 files (285 added in new service, 372
removed from controllers)
- Single source of truth for balance metrics computation
- Eliminated 3 copies of `convertBalance()` and 2 copies of the net
worth evolution loop
- Removed 3 redundant database queries from account show page

## Verification

### Tests
- All 84 related tests pass (AccountController, AccountBalance,
Dashboard, DashboardAnalytics, BalanceLookup, CashflowAnalytics) with
529 assertions
- No test changes needed — the refactoring preserves exact output shapes
2026-02-24 09:07:42 +01:00
Víctor Falcón ae81e20a66
perf(dashboard): optimize query performance and eliminate redundant requests (#146)
## Why

### Problem

The dashboard page takes ~4 seconds to load for users with many balance
records. Profiling revealed 29 queries across the initial page load + 3
separate API calls, a 576 KB wasted payload from unused bank records,
and a critical 3.7s bottleneck in `BalanceLookup` correlated subqueries.

## What

### Changes

**Query optimizations:**
- Replace correlated `MAX()` subqueries in `BalanceLookup` with
derived-table `joinSub` pattern — **48x faster** (3,693ms → 77ms) on
accounts with thousands of balance records
- Replace `whereHas` (EXISTS subqueries) with JOINs in
`DashboardAnalyticsController` and `CashflowAnalyticsController` — ~3x
faster per query
- Cache encryption check results in `HandleInertiaRequests` middleware
to avoid 2 duplicate queries per request

**Payload & request reduction:**
- Remove dead `banks` query from `DashboardController` (2,365 records,
576 KB never used by frontend)
- Remove duplicate `categories` and `accounts` queries already provided
by middleware shared props
- Consolidate 3 separate `fetch()` API calls into Inertia v2
`Inertia::defer()` props grouped under `'dashboard'` (single follow-up
request with skeleton fallbacks)

**Frontend:**
- Replace `useDashboardData` fetch hook with `usePage()` props +
`<Deferred>` components
- Convert `CashflowSummaryCard` from internal fetch to prop-based
- Use `router.reload({ only: [...] })` for balance update refetch

### Performance summary

| Metric | Before | After |
|--------|--------|-------|
| Initial page queries | 16 | 12 |
| Follow-up HTTP requests | 3 separate API calls | 1 Inertia deferred |
| BalanceLookup time | 3,693ms | 77ms |
| Wasted payload | 576 KB | 0 |

## Verification

### Tests

All 633 feature tests pass, including 40 dashboard/cashflow-specific
tests.
2026-02-24 08:43:48 +01:00
Víctor Falcón 2dc8cb554c
Remove encryption from bank account names (#104)
## Summary

- Store account names in plaintext instead of encrypting them
client-side. Encryption remains only for transaction descriptions and
notes.
- Existing encrypted account names are silently migrated when the user
unlocks their encryption key, via a new `useDecryptAccountNames` hook
that calls API endpoints to update each account.
- New `AccountName` component handles rendering both encrypted (legacy)
and plaintext accounts across the app.

## Test plan

- [x] Create a new account — name should be stored in plaintext
(`encrypted = false`, `name_iv = null`)
- [x] Unlock encryption key with existing encrypted accounts — they
should auto-migrate silently
- [x] After migration, verify accounts show `encrypted = false` in DB
and names are readable plaintext
- [x] Edit a migrated account — should work without encryption
- [x] Verify all account name displays work across dashboard, account
details, transactions, settings
2026-02-09 14:15:26 +01:00
Víctor Falcón d16282dbad
feat: Auto-open encryption key modal after login (#54)
## Summary

This PR implements automatic opening of the encryption key unlock modal
immediately after user login when they visit the dashboard. The modal
will only appear once per login session and will not show up on
subsequent dashboard visits or if the user manually clears their
encryption key.

## Problem

Previously, users had to manually click the encryption key button in the
sidebar after logging in to unlock their encrypted data. This extra step
could be confusing for new users and added friction to the login flow.

## Solution

The encryption key modal now automatically opens when users visit the
dashboard immediately after logging in, but **only if**:
1. The user just completed a login (session flag is set)
2. Their encryption key is not already in browser storage
3. Encrypted challenge data is available

## Implementation Details

### Backend Changes

#### 1. Custom Login Response Classes
Created two new response classes that override Fortify's default login
responses:

**`app/Http/Responses/LoginResponse.php`**
- Implements `LoginResponse` contract
- Sets `show_encryption_prompt` session flash variable after successful
login
- Handles both regular login and JSON API responses

**`app/Http/Responses/TwoFactorLoginResponse.php`**
- Implements `TwoFactorLoginResponse` contract  
- Sets the same session flag after successful 2FA authentication
- Ensures the modal appears for 2FA users as well

#### 2. Service Provider Registration
**`app/Providers/FortifyServiceProvider.php`**
- Registered custom response classes as singletons in the container
- Fortify now uses our custom responses instead of defaults

#### 3. Dashboard Controller
**`app/Http/Controllers/DashboardController.php`**
- Passes `showEncryptionPrompt` flag from session to frontend
- Uses `session('show_encryption_prompt', false)` which automatically
consumes the flash data

### Frontend Changes

**`resources/js/pages/dashboard.tsx`**
- Added `DashboardProps` interface extending `SharedData` with
`showEncryptionPrompt` boolean
- Updated `useEffect` hook to check three conditions before auto-opening
modal:
1. `props.showEncryptionPrompt` - User just logged in (from session
flash)
  2. `!isKeySet` - Encryption key not in browser storage
  3. `encryptedMessageData` - Encrypted challenge data loaded
- Includes `UnlockMessageDialog` component that conditionally renders

## How It Works

### User Flow

1. **User logs in** → Custom `LoginResponse` or `TwoFactorLoginResponse`
sets `show_encryption_prompt` flash session variable
2. **Redirect to dashboard** → `DashboardController` retrieves and
passes the flag to frontend (consuming it)
3. **Dashboard loads** → React component checks all three conditions
4. **Modal appears** → If conditions are met, `UnlockMessageDialog`
opens automatically
5. **Session flag consumed** → Subsequent page loads won't trigger the
modal

### Why Session Flash?

Laravel's session flash data is perfect for this use case because:
- It's automatically removed after being accessed once
- It survives redirects (crucial for post-login flow)
- It's server-side, so it can't be manipulated by client-side code
- No database changes or additional state management needed

### Edge Cases Handled

 **User clears encryption key manually** → Modal won't auto-open on
dashboard visit (no session flag)
 **User refreshes dashboard** → Modal won't re-appear (flash data
consumed)
 **User logs out and back in** → Modal appears again (new session flag)
 **2FA enabled users** → Modal appears after successful 2FA challenge
 **Key already in browser** → Modal doesn't appear (key check passes)

## Testing

-  All existing dashboard tests pass (2 tests)
-  All authentication tests pass (58 tests)
-  Code formatted with Laravel Pint
-  Code formatted with Prettier
-  ESLint validation passes

## Files Changed

- `app/Http/Responses/LoginResponse.php` (new)
- `app/Http/Responses/TwoFactorLoginResponse.php` (new)
- `app/Providers/FortifyServiceProvider.php` (modified)
- `app/Http/Controllers/DashboardController.php` (modified)
- `resources/js/pages/dashboard.tsx` (modified)

## Breaking Changes

None. This is an additive feature that enhances the existing
authentication flow without breaking any existing functionality.
2026-01-11 20:29:49 +01:00
Víctor Falcón 595057c66e Add dashboard analytics backend
- Create DashboardAnalyticsController with 6 analytics endpoints:
  - Net worth calculation and comparison
  - Monthly spending with period comparison
  - Cash flow (income vs expenses)
  - Net worth evolution over time
  - Account balance history
  - Top spending categories
- Add PeriodComparator service for date range comparisons
- Update DashboardController to pass categories, accounts, and banks
- Add comprehensive feature tests for all analytics endpoints
- Register analytics routes under /api/dashboard
2025-12-01 10:30:35 +01:00