Architecture
Three layersโ
- Frontend (browser) โ the whole UI, all calculations (financial health,
FIRE, projections, insights), in-app notifications, end-to-end encryption
of the data, and local mode (
localStorage). Works without a server. - Managed backend โ Supabase โ database (households, subscriptions), Auth, Row Level Security and Postgres functions (access logic running in the database).
- Custom server code โ serverless functions (
/api/*routes in Next.js) needed wherever a secret or a trust boundary is involved โ above all payments (webhook, checkout) and sending email / web push.
What needs a serverโ
- Payment webhook โ verifies the signature and writes subscription status with the service-role key (which must never reach the browser).
- Creating checkout โ calls the provider's API with a secret key.
- Email / web-push notifications โ require scheduling and keys.
What a server is NOT needed forโ
Storing the plan, signing in, financial calculations, insights, in-app notifications and encryption โ handled by the frontend + Supabase. The encryption key is derived on the device and never sent to the server.
Performanceโ
- Code splitting. Screens for signed-out visitors (landing page, beta, legal documents), the import wizard, and the German and French dictionaries are separate bundles. A signed-in user doesn't download them until they're needed. The landing page is still rendered on the server, so search engines see its content.
- Language. Polish and English ship in the initial bundle. When you pick German, its dictionary loads in the background; English is shown meanwhile.
- Prices in the background. While the tab or app is in the background, price refreshes (crypto, ETFs, stocks) pause. When you come back and a full refresh period has passed, prices are fetched right away.
- Analysis. The health score, returns (XIRR, TWR), FIRE and the timeline are computed once per data change, not on every tab switch.
- Dashboard. The health score, the 12-month trend, the "what changed" sentence and the ritual status are computed once per data change โ likewise the main-goal card and the side rail. Switching tabs or opening panels recomputes nothing.
- Portfolio. On desktop with the "Investment details" module only the table is mounted, on phones only the cards (previously both, one hidden). Rows re-render only when their own position changes; each projection is computed once per data or horizon change.
- Dashboard. On phones only the active tab and the compact structure chart are mounted; on desktop all sections, the chart with legend and the income chart โ no hidden copies.
- Choosing the variant (phone / desktop) knows the window width from the very first paint, so the mobile version never flashes on a computer.
- Startup bundle. The Supabase library (cloud account), the demo data, the EN/DE/FR dictionaries and the Dashboard code load only when they're needed โ the start page and the app in local mode download less code. The right-hand summary panel (from 1280 px) isn't computed or rendered at all on narrower screens.
- Saving state. Changes reach browser storage after a short delay (a burst of changes = one write), and immediately when the tab is closed or hidden. Plan text fields (plan name, categories, other FIRE income) save when you leave them.
- Navigation has no background blur (it was recomputed every scroll frame); the status bar runs on desktop only.
- GrapeVest mark. The logo gradients are defined once per page, so the logo renders identically on the server and in the browser (no re-render on load).
PWAโ
The manifest + service worker enable installing on a phone and offline use in local mode. They're also the foundation for future web push.
Updates: after a new deployment the fresh service worker waits in the
waiting state and the app shows a "New version โ Refresh" toast. Clicking
sends SKIP_WAITING to the SW and the page reloads onto the new version after
controllerchange โ the user never silently stays on a stale cache, and we
never interrupt their work unasked.
Three guards make sure a tab that lives through a deployment doesn't get stuck on old code (version skew):
- Update checks on tab focus. The SW registration checks for updates not only at startup but whenever the tab regains focus (throttled), so the "New version" toast also appears in sessions that outlived a deployment โ including tabs restored from bfcache.
- RSC navigation payloads always from the network. The service worker never
caches router responses (the
RSCheader /_rscparam) โ a stale payload would reference the previous deployment's JS chunks and pin the old version. - The
X-App-Buildheader. The server reports the currently deployed build hash. When a file import fails, the app compares it with the build compiled into the running tab and โ on mismatch โ asks for a page refresh instead of showing a misleading error.
Top safe area (notch / status bar). The whole layout uses
viewport-fit: cover, so content reaches the screen edges โ the top gap below the
status bar (clock, network, battery, notch / Dynamic Island) comes from
env(safe-area-inset-top). On some iPhones WebKit can report that value as 0
(both in Safari and in the home-screen PWA), which pushed the header and the
floating buttons under the status bar. So the top inset has a guaranteed
minimum: the CSS variable --safe-area-top = max(env(safe-area-inset-top), 1.5rem). On healthy devices it's a no-op; when env wrongly reports 0, the layout
still keeps a safe gap. We use it everywhere the top edge matters (app shell,
full-screen screens, the โ๏ธ / invite buttons, slide-in panels).
Scrolling on mobile. The only scroller is the .app-scroll container
(position: fixed; inset: 0) โ not the window (body { overflow: hidden }). Two
consequences:
- No horizontal scroll.
.app-scrollclips horizontally viaoverflow-x: hidden(notclipโ iOS < 16 ignores that, and withoverflow-y: autoit computes toauto, bringing back a horizontal bar). - New page always starts at the top. Next.js's default scroll-to-top targets
the window, so it does nothing here โ the
ScrollToTopOnNavcomponent resets the container's scroll on pathname change (window.scrollTowas tried and made things worse). A query-only change (/?saved=โฆ) does not scroll.