Skip to main content

Architecture

Three layersโ€‹

  1. 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.
  2. Managed backend โ€” Supabase โ€” database (households, subscriptions), Auth, Row Level Security and Postgres functions (access logic running in the database).
  3. 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 RSC header / _rsc param) โ€” a stale payload would reference the previous deployment's JS chunks and pin the old version.
  • The X-App-Build header. 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-scroll clips horizontally via overflow-x: hidden (not clip โ€” iOS < 16 ignores that, and with overflow-y: auto it computes to auto, 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 ScrollToTopOnNav component resets the container's scroll on pathname change (window.scrollTo was tried and made things worse). A query-only change (/?saved=โ€ฆ) does not scroll.