Upgrades Inertia.js from v2 to v3 on both sides — the major deliberately
left out of #764.
| Package | From | To |
| --- | --- | --- |
| `inertiajs/inertia-laravel` | 2.0.24 | **3.3.1** |
| `@inertiajs/react` | 2.3.17 | **3.6.1** |
| `axios` | transitive | **direct dep** (1.19.0) |
## Why axios becomes a direct dependency
The app imports `axios` in 18 runtime files, but it was never declared —
it arrived transitively through `@inertiajs/core` v2. v3 drops axios in
favour of its own XHR client, so without this the whole app would fail
to resolve the import.
Nothing relied on Inertia configuring axios: v2 never touched
`axios.defaults`, and there are no interceptors. `^1.19.0` also
satisfies core's optional peer range (`^1.15.2`).
## Breaking changes swept
Every v2→v3 breaking change from the [official upgrade
guide](https://inertiajs.com/upgrade-guide) was checked against the
codebase:
| Change | Call sites |
| --- | --- |
| `<title inertia>` → `<title data-inertia>` | fixed (`app.blade.php`) |
| `testing.page_paths/page_extensions` → `pages.paths/extensions` |
fixed (`config/inertia.php`) |
| `ComponentResolver` no longer accepts `Promise<{default}>` | fixed
(`app.tsx`, `ssr.tsx`) |
| `router.on('invalid'/'exception')` → `httpException`/`networkError` |
none — only `'navigate'` is used |
| `router.cancel()` → `cancelAll()` | none |
| `Inertia::lazy()` → `optional()` | none |
| `future` options block | none |
| `hideProgress`/`revealProgress` | none |
| arrow-fn `.layout =` → `[Layout]` | none — no page sets `.layout` |
| deprecated testing traits (`Has`/`Matching`/`Debugging`) | none |
| `qs` / `lodash-es` imports | none |
React 19.2.4, Laravel 13 and PHP 8.4 already satisfy v3's raised floors.
## DevTools kept opt-in
v3 adds a DevTools recorder that writes every request's page props to
`storage/inertia-devtools/` and **defaults to on in local**. On this app
that produced 38 dumps, the largest ~1 MB, holding whole transaction and
balance payloads — redaction only covers credential-shaped keys. It is
gitignored by the package, so it never reaches the repo, but for a
privacy-first product it shouldn't be the default. Gated behind
`INERTIA_DEVTOOLS_ENABLED` (set it to `true` in your own `.env` when you
need it).
## Deliberate behaviour change: `<Deferred>` on partial reloads
v3 no longer resets `<Deferred>` to its `fallback` during a partial
reload — existing content stays visible while fresh data loads. This
affects the dashboard (`onBalanceUpdated`) and account detail
(`handleTransactionCreated`).
**Kept as-is, on purpose.** Two reasons:
1. `dashboard.tsx:131-133` already carried a workaround for exactly this
v2 behaviour — it requests an unrelated cheap prop purely so the
deferred prop isn't refetched into a skeleton. The team already treated
the skeleton flash as a problem and v3 fixes it upstream, so restoring
it via the new `reloading` slot prop would undo an improvement. That
workaround is now redundant and can be simplified in a follow-up.
2. The "user can't tell it worked and re-submits" risk doesn't apply:
`edit-transaction-dialog.tsx:390` fires `toast.success('Transaction
created successfully')` *before* the reload, so the confirmation is
explicit and independent of the list.
## Verification
Full CI set, locally:
- `bun run build` — green
- `bun run test` — **353/353**
- `bun run types` — zero new errors (advisory step; pre-existing backlog
unchanged)
- `bun run format`, `bun run lint`, `bun run dry` — green
- `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` — green
- `php artisan crap --base=origin/main` — 0 methods above complexity 10
- `pest --exclude-testsuite=Browser,Performance` — **2086/2088**
- `pest --testsuite=Browser` — **132/133**
Both test failures are pre-existing and reproduce identically on `main`
with Inertia v2 — I ran each one there to confirm:
- `DashboardTest::dashboard top categories roll child spending up into
the parent` — 409, the known local Inertia asset-version artifact
- `BudgetsFeatureNavigationTest::user cannot access another users
budget` — `Unknown column 'category_id'` on `budgets`, a factory/schema
mismatch
## Browser QA
Real browser sweep against the production build over dashboard,
transactions, accounts, budgets, settings and billing: **0 console
errors, 0 uncaught exceptions, 0 failed requests**. Page titles apply,
deferred props resolve, sidebar `<Link>` navigation stays client-side
with no document reloads, back/forward restores from Inertia history,
and a profile form submit persisted correctly.
## SSR: measured, left alone
v3 routes SSR through the Vite dev server (`/__inertia_ssr`) whenever
Vite is running hot, and we don't use the `@inertiajs/vite` plugin — so
in theory local dev makes a doomed POST per render. Measured it rather
than guessed: with `bun run dev` up, page loads are **16 ms** and the
failed attempt is a connection-refused costing ~0.6 ms, then it falls
back to client rendering. Dev mode renders correctly (React mounts, 0
console errors). Not worth a config knob.
Production is untouched — `Vite::isRunningHot()` is false there, so it
uses the real SSR server from `docker/supervisor/supervisord.conf`
exactly as on v2.
## Notes
- Wayfinder needed nothing: #764 already brought it to v0.1.21 (latest),
and `@laravel/vite-plugin-wayfinder` is on 0.1.7 (latest).
- Pre-existing bug both reviews surfaced, left for its own PR:
`auth/register.tsx:40` passes an `async` handler to `<Form onBefore>`,
which Inertia never awaits (same in v2 and v3), so
`transactionSyncService.clearAll()` is fire-and-forget on registration.
- `config/inertia.php` is hand-patched rather than republished. The v3
stub is ~190 lines; the unset keys resolve to package defaults via
`mergeConfigFrom`. Adopting the whole stub is a separate, reviewable
change.
- Remaining "Inertia v2" strings live inside Boost-generated blocks
(`CLAUDE.md`'s guidelines block and the vendored `SKILL.md` copies).
Hand-edits there get overwritten, so they should come from a Boost
regeneration. The three hand-written references are updated here.
## Demo
https://github.com/user-attachments/assets/3f0e7f6d-621a-4c3a-a2a0-68473ec59f10
<!-- PLACEHOLDER: drag the QA video in here -->
Adds two code-quality signals to CI, with deliberately different
strengths.
## Duplication (blocks merges)
`bun run dry` runs jscpd as a step in the `linter` job, a required
check, so
copy-paste that pushes duplication above the threshold in `.jscpd.json`
blocks
the merge. Baseline today is 5.36% (PHP 6.01%, TSX 5.82%) and the
threshold sits
at 5.4. jscpd is pinned as a devDependency rather than run through
`bunx`: a
version bump changes the reported number and would fail unrelated PRs.
## Complexity (goes red, does not block)
`php artisan crap` reports cyclomatic complexity per method. A separate
`crap`
job reports the methods a PR touched that exceed complexity 10. It is
**not** a
required check: it goes red so the number is visible, but never blocks a
merge.
Folding it into the `linter` job would turn it into a gate, which is not
the
intent — please keep it out of branch protection.
### Why complexity decides the verdict and not CRAP
CRAP is `c² × (1 − coverage)³ + c`, so it is complexity penalised by
missing
tests. Measured here, it is the wrong signal for readability:
| | Methods over 30 | crapLoad | totalCrap |
|---|---:|---:|---:|
| Whole suite with real coverage | 15 (0.99%) | 100 | 5,031 |
Because `app/` is well covered, CRAP mostly ranks what is untested. The
two
rankings barely overlap:
| Rank | By CRAP | By complexity |
|---|---|---|
| 1 | `VerifyRefundFlowCommand::handle` (c=14, 0%) |
`ExperimentFunnelCollector::collect` (c=35, 99%) |
| 2 | `StripeCustomerResolver::label` (c=10, 0%) |
`DashboardAnalyticsController::accountBalanceEvolution` (c=30, 91%) |
| 3 | `WiseTransactionSyncService::parseActivity` (c=8, 0%) |
`UpdateTransaction::write` (c=20, 75%) |
The most complex method in the codebase — 35 branches — is 99% covered,
so it
scores CRAP 35 and lands 11th of 15, below an untested enum `label()` of
complexity 6. An agent guided by CRAP would write tests for a console
command
and leave the 35-branch method alone. So complexity triggers the check,
and CRAP
plus per-method coverage travel in the output as context for *how* to
fix it.
### Threshold
10, McCabe's number, just above this codebase's p95 of 8 (median 1, p99
14,
max 35). It fires only on methods a diff touches, so the 35 existing
offenders
only matter when someone edits them.
## The command
```bash
php artisan crap # whole project, ranked
php artisan crap --base=origin/main --no-coverage # what CI checks, <1s
php artisan crap --base=origin/main --json # for agents
```
- `--no-coverage` skips the coverage report and gives the same verdict,
since
complexity alone decides it. Without it, a crap4j report is required and
the
command refuses to guess when it is missing, printing the exact command
to
generate one.
- Exemptions live in `.crap-ignore.json` keyed by method, with a reason
that is
read in review. Entries that are no longer needed get reported for
deletion.
- Untracked files count whole — a new feature is mostly new files and
would
otherwise sail through unmeasured.
- Exit codes: 0 clean, 1 over threshold, 2 unusable input.
The counter is php-code-coverage's own
`CyclomaticComplexityCalculatingVisitor`,
so the numbers match the CRAP it reports: **1502 of 1503 methods
agree**. Its
wrapping visitor is not reused because that one asserts a method's
parent is a
class or a trait, which fails on enums.
## Notes for review
- `pcov.directory` is set explicitly in the tests job. Left to
autodetect, pcov
picks `src`, which does not exist in a Laravel app, and every method
silently
reports 0% coverage — which is how the first measurements of this metric
came
out wrong, with plausible-looking numbers.
- The `crap` job skips rather than fails when `tests` fails: without the
coverage
artifact, a red `crap` job would say nothing about complexity.
- The two checks cover each other's blind spot. Splitting a complex
method into
near-identical pieces to lower complexity raises duplication, and that
check
does block.
- **Not verified:** that paratest merges coverage across its 4 processes
in CI.
Locally only the serial run works — in parallel each worker starts its
own
MySQL testcontainer and they time out. If it misbehaves, the symptom is
empty
crap/coverage context columns, not a wrong verdict.
- Scope is PHP only. `resources/js` (392 files, 72k lines vs 435/37k in
`app/`)
has no complexity pipeline; the JSON says so explicitly rather than
letting
"whole project" be assumed.
## Testing
10 Pest tests covering the threshold verdict, the CCN counting rules
(including
match arms and closures nested in a method), enum methods, exemptions,
stale
exemptions, the missing-report refusal, the crap4j join, and the
reported scope.
`pint --test` and the full `phpstan` run are clean.
Patch release cut with `release-it`.
- Bumps `package.json` to `0.2.7`
- Regenerates `CHANGELOG.md` (conventional-changelog, angular preset)
and enriches it via `scripts/enrich-changelog.js`
Tag `v0.2.7` and the GitHub release will be created on `main` once this
is merged.
## What & why
The dashboard white-screened with React **"Maximum update depth
exceeded"** on mobile (iOS 18.7 / Opera Touch), fingerprinted in Sentry
as **PHP-LARAVEL-47** (`https://whisper.money/dashboard`).
### Root cause (upstream, recharts < 3.9.0)
The crashing first-party frame is inside recharts' internal
`CartesianChart` → `useElementOffset`
(`node_modules/recharts/es6/util/useElementOffset.js`). In < 3.9.0 that
hook:
- memoizes its measuring **callback ref** with `useCallback(...,
[lastBoundingBox.width, .height, .top, .left, ...])`, so **every
`setState` changes the ref identity → React re-invokes the ref → it
re-measures on each render**, and
- reads **viewport-relative** `getBoundingClientRect().top/left` with a
`EPS = 1px` threshold.
On mobile, any mid-render viewport shift (the iOS URL bar showing/hiding
on scroll) moves the box `> 1px` on each commit, feeding an unbounded
`render → measure → setState` loop until React aborts at the
nested-update limit and the dashboard white-screens.
This is the **same crash family** as the `ChartTooltipPortal` loop fixed
in #657, but a **distinct root cause inside recharts** (that first-party
loop is already fixed and covered by its own test; both are present on
this branch).
### The fix
Upgrade **recharts 3.7.0 → 3.9.2**. 3.9.0 rewrote `useElementOffset` to
drive measurement off a **`ResizeObserver` with a stable callback ref**
(deps no longer include the last bounding box). `ResizeObserver` fires
on element **size** changes, not on scroll/viewport-position — so the
iOS URL-bar shift no longer re-triggers measurement. This removes the
loop at its source; it does not merely mask it.
## Commits
1. `fix(chart): upgrade recharts to 3.9.2 to stop the dashboard render
loop` — the fix (bun.lock + a code comment on `ResponsiveContainer`
recording the `>= 3.9.0` requirement).
2. `fix(chart): raise recharts floor to ^3.9.0 so the crash fix can't
regress` — review follow-up: `package.json` was still `^3.7.0` (permits
the crashing versions); bumped to `^3.9.0` and regenerated
`package-lock.json` so both `bun install --frozen-lockfile` and `npm ci`
enforce the floor. Also resolves a pre-existing
bun.lock/package-lock.json divergence.
3. `test(chart): guard recharts >= 3.9.0 against a downgrade regression`
— asserts the installed recharts is `>= 3.9.0` (the honest test for a
"depend on the patched version" fix; a jsdom behavioral repro is
impossible — zero-size rects, no ResizeObserver — and would pass on the
buggy version too).
## Review (two agents, before this PR)
Both reviewed against the actual `node_modules/recharts@3.9.2` source,
not just release notes.
- **No material regression risk.** The `ChartTooltipPortal`'s two hard
dependencies still hold in 3.9.2: `.recharts-wrapper` is still emitted
and is still the default tooltip portal target, and `Tooltip` still
passes `coordinate` as `{ x, y }`. `ResponsiveContainer`'s
`initialDimension` is still supported. No API we use was
removed/renamed.
- **Types & tests:** `bun run types` produces zero recharts-attributable
errors; the chart-related JS tests (tooltip portal/position,
budget-spending-chart, stacked-bar custom-shape path) pass on 3.9.2.
### Lockfile note
The production bundle is built by `bun run build` off **bun.lock**
(pinned to 3.9.2). `package-lock.json` is consumed **only** by
`release.yml`'s `npm ci`, which runs `release-it`
(versioning/changelog/tag) and does **not** build the shipped bundle —
so its large regen diff is mechanical drift-repair and cannot affect
users. Follow-up worth considering (out of scope): consolidate on bun
and drop the vestigial `package-lock.json`.
## Why draft (not auto-merged)
The root-cause fix is high-confidence, but this is a **charting-library
bump touching all chart surfaces** and the original bug is
**mobile-only**, which I can't visually QA here. Please spot-check
before merge:
- [ ] **Mobile dashboard** (ideally iOS Safari + Opera Touch): charts
render, no white-screen, scroll is smooth, tooltips position correctly.
- [ ] **Animations**: recharts 3.9 rewrote the animation system — glance
that Area/Bar/Line still animate acceptably on data refresh (esp.
`account-balance-chart`, `budget-spending-chart`).
- [ ] **New Sentry noise**: watch for `"ResizeObserver loop completed
with undelivered notifications"` — that's a benign warning, not the loop
returning; suppress rather than treat as a regression.
Refs PHP-LARAVEL-47
## What
Let users reorder their accounts by drag-and-drop. The order is shared
between the **dashboard** and the **accounts page**, and persisted
server-side.
## Why
The account order was fixed (by type, then name). Users want to put the
accounts they care about first, consistently across both views.
## How
**Backend**
- New `position` column on `accounts`, backfilled per user from the
previous type/name ordering so existing layouts are preserved.
- `PATCH /accounts/reorder` (`AccountController@reorder` +
`ReorderAccountsRequest`) persists the order and validates ownership of
every id.
- Dashboard and accounts queries now `orderBy('position')`. `position`
is cast to int and hidden from the serialized payload (order is conveyed
by array order).
**Frontend**
- Shared `SortableGrid` component built on `@dnd-kit` (new dependency).
Pointer drag starts after a small move (clicks still work); touch drag
starts on a long press, so quick swipes still scroll.
- The drag handle swaps with the account type icon on hover — top-right
on the dashboard card, bottom-left on the accounts card.
- The accounts page is now a flat list (type grouping dropped) so its
order matches the dashboard exactly.
- Reorder is optimistic and avoids refetching the deferred dashboard
metrics.
- Haptic feedback (`'selection'`, same as the mobile menu) fires when a
drag starts on touch.
- On mobile the accounts card stacks vertically (name / amount / trend)
and hides the redundant bank-name subtitle.
## Tests
- `reorder` persists positions and rejects accounts the user doesn't
own.
- Index ordering updated to assert `position` order.
- Existing account/dashboard/real-estate suites updated and green.
## Notes / follow-ups
- New accounts get `position = 0` (appear first) — can add `position =
max+1` on create later.
- On mobile the whole subtitle is hidden, including "Mortgage at X" for
real estate.
- Mobile drag-and-drop discoverability (the handle only shows on hover)
is still open — discussed but not yet decided.
## What
- **Markdown comments**: admin comments on the integration board now
render as markdown (`react-markdown` + `remark-gfm`) instead of plain
text. Links open in a new tab; blockquotes, lists and bold are styled.
Comments are admin-only (set via CLI), so the content is trusted.
- **`in_progress` status**: new status, visible to everyone and votable
like `approved`, with an optional public comment. Lets the admin signal
an integration is actively being built.
- **`integration-requests:review` rework**:
- Any decision can now move a request to any status (approve / in
progress / reject / not doable), not just the pending ones.
- New `--all` flag prints the full list with a `#` column so the admin
can pick a request by number and change its status.
- `not doable` requires a comment; `in progress` allows an optional one;
approve/reject clears any stale public comment.
## New dependencies
- `react-markdown`, `remark-gfm` (approved with the author).
## Tests
- 24 feature tests passing, incl. `--all` status change, `in_progress`
visibility + voting, and updated review-command option sets.
- Frontend vitest covering markdown rendering (link + blockquote).
Patch release **v0.2.5** (0.2.4 → 0.2.5).
### Cambios
- Bump de versión + `CHANGELOG.md` generados con release-it
(conventional-changelog).
- **Autoría de PRs en el changelog**: cada entry muestra `by [@handle]`,
resuelto por número de PR vía la API de GitHub.
- `scripts/enrich-changelog.js`: enriquece solo la sección de release
más reciente; idempotente y no-fatal si `gh` no está disponible.
- Hook `after:bump` en `.release-it.json` para que se aplique
automáticamente en cada release futura.
Tras el merge: creo el tag `v0.2.5` y el GitHub release sobre `main`.
Patch release `v0.2.4`. Bumps version + updates CHANGELOG via
release-it.
Tag `v0.2.4` + GitHub Release created after merge (tag must point at the
merged commit).
## Summary
- Replace Caddy reverse proxy with [Portless](https://portless.sh) for
local HTTPS, eliminating manual cert generation and `/etc/hosts` editing
- Two URL strategies coexist: `composer run dev` uses worktree-aware
URLs (`https://<branch>.dev.whisper.money.localhost`), `whispermoney
start` uses a fixed alias (`https://whisper.money.localhost`)
- Remove Caddyfile, `docker/caddy/` directory, caddy service from
`compose.yaml`, and cert-related `.gitignore` entries
- Simplify `vite.config.ts` by removing caddy cert detection block (Vite
stays on plain HTTP since browsers treat localhost as secure context)
- Overhaul `public/setup.sh` to use `portless trust`, `portless proxy
start`, and `portless alias` instead of SSL cert generation and hosts
file editing
## Summary
- Installs `web-haptics` via bun
- Triggers `selection` haptic feedback on all navigation items (mobile
bottom bar and desktop sidebar)
- Triggers `light` haptic feedback on all back buttons
(`connect-account-inline.tsx`, `categorize.tsx`)
## Why
### Problem
Running `bun release -i patch` (or any interactive `release-it`
invocation) always failed with:
```
ERROR stringWidth is not a function
```
### Root Cause
`@inquirer/core` (used by `inquirer@12`, which `release-it@19` depends
on) bundles its own `wrap-ansi@6.2.0` (CJS). That package calls
`require('string-width')`, but the top-level `string-width` in this
project is v8 — pure ESM. When required via CJS, Node returns a module
namespace object instead of a function, causing the crash when
`release-it` tried to render its interactive confirmation prompt.
## What
- Added `scripts/patch-inquirer-string-width.js`: installs
CJS-compatible versions of `string-width@4`, `strip-ansi@6`,
`ansi-regex@5`, and `is-fullwidth-code-point@3` into
`node_modules/@inquirer/core/node_modules/` so `wrap-ansi@6` resolves
them correctly.
- Added a `postinstall` script to `package.json` so the patch is
re-applied automatically after every `bun install`.
## Verification
### Tests
No automated tests — this is a dev tooling fix. Verified manually by
running `node node_modules/release-it/bin/release-it.js --dry-run` and
confirming the interactive prompt renders without the `stringWidth`
error.
### Manual
1. `bun install` — postinstall script runs and outputs `Patch applied.`
2. `bun release -i patch` — interactive prompt now renders correctly.
## Why
### Problem
Cards across the app lacked visual interactivity. Adding a
cursor-tracking glowing border effect improves the UI polish and makes
the dashboard and other card-heavy pages feel more dynamic.
## What
### Changes
- Install `motion` package (required for the `animate()` call in the
effect)
- Add `GlowingEffect` component at
`resources/js/components/ui/glowing-effect.tsx` — tracks pointer
position and renders an animated conic gradient border that follows the
cursor
- Update the base `Card` primitive to include `<GlowingEffect>` as the
first child with `spread=40`, `proximity=64`, `inactiveZone=0.01`,
`glow=true` — all consumers inherit the effect automatically
- Remove `overflow-hidden` from the net-worth chart's `<Card>` (it was
clipping the effect) and move it to `<CardContent>` where it's actually
needed to contain the chart
## Verification
### Tests
No new logic was introduced — this is a purely visual enhancement to an
existing UI primitive. Existing tests remain unaffected.
### Manual
Move the cursor near and over any card (dashboard stats, account
balances, net worth chart, cashflow, budgets) to see the gradient border
glow follow the cursor.
## Summary
- Replaced the default initials-based avatar fallback with
[Facehash](https://facehash.dev/) — deterministic, unique avatar faces
generated from the user's name
- Same name always produces the same face, no API calls needed
- Fully rounded with `rounded-full` styling
## Preview

Each user gets a unique, consistent face based on their name — no more
generic initials.
## Changes
- Installed `facehash` package
- Updated `resources/js/components/user-info.tsx` to use `<Facehash>`
instead of `<Avatar>` with initials fallback
## Test plan
- [x] Verify avatar renders correctly in the sidebar footer (desktop)
- [x] Verify avatar renders correctly in the mobile header
- [x] Verify avatar renders correctly in the user dropdown menu
- [x] Confirm the same user always gets the same face
- [x] Check dark mode appearance
- Add version field to package.json (0.1.0)
- Install release-it for automated releases with conventional changelog
- Create .release-it.json configuration
- Create initial CHANGELOG.md
- Share version via Inertia (reads from package.json)
- Display version in user dropdown menu
## Summary
- Add four view modes for net worth and account balance charts:
- **Stacked Accounts**: existing stacked bar chart showing balances by
account
- **Line**: net worth line chart with linear/log scale toggle
- **Change**: bar chart showing MoM%, YoY%, Rolling 12M changes
- **Waterfall**: bridge chart showing Start -> Change -> End transition
- Add Vitest for frontend unit testing with 52 tests covering all
calculation functions
- Proper sign handling for liabilities (credit cards and loans subtract
from net worth)
- Log scale disabled with warning when net worth includes zero or
negative values
- Shared components and logic between dashboard and account detail
charts
## New Files
- `vitest.config.ts` - Vitest configuration
- `resources/js/lib/chart-calculations.ts` - Pure computation functions
- `resources/js/lib/chart-calculations.test.ts` - 52 unit tests
- `resources/js/hooks/use-chart-views.ts` - Shared chart view state hook
- `resources/js/components/charts/` - Reusable chart components
## Test plan
- [x] Dashboard net worth chart shows view toggle and all four views
work
- [ ] Account detail page chart shows view toggle and three views work
(no stacked)
- [x] Line chart scale toggle works (log disabled with warning when
applicable)
- [x] Change chart series toggle (MoM, YoY, 12M EUR, 12M %) works
- [x] Waterfall chart month selector works
- [x] All 52 unit tests pass (`bun run test`)
- [x] Build succeeds (`bun run build`)
- [x] Dark mode styling correct