1. Wiki
  2. Before It's Gone
  3. Architecture

Architecture

This page describes the technical structure of Before It's Gone: how the codebase is organized, how data moves between layers, and where to look when adding or changing something.

Monorepo layout

Before-Its-Gone/
├── packages/
│   ├── core/          # Business logic, database, types (shared)
│   └── ui/            # Shared React components
├── apps/
│   ├── web/           # Vite + React renderer
│   └── electron/      # Electron main process
└── .github/workflows/ # CI and release automation

All packages are npm workspaces. The dependency graph is strictly one-directional:

apps/electron  ──►  packages/core
apps/web       ──►  packages/core
apps/web       ──►  packages/ui
packages/ui    ──►  packages/core

packages/core has no dependency on any app. This keeps business logic portable and testable without Electron or a browser.

Packages

packages/core

All inventory logic, storage, and shared types.

FileResponsibility
src/models.tsTypeScript interfaces: InventoryItem, BarcodeProfile, WasteLogEntry, AppSettings, SyncSettings
src/storage.tsIndexedDB schema (via idb), CRUD operations, BrowserLocalStorageAdapter
src/inventory.tsgetFilteredInventory(), calculateExpiryStatus(), parseInventoryCSV(), parseInventoryJSON()
src/expiry-prediction.tsShelf-life lookup by food category
src/email-templates.tsHTML email template functions (pure, no dependencies)
src/services/InventoryService.tsHigh-level service wrapping storage calls
src/services/ImportExportService.tsCSV/JSON import-export orchestration
src/index.tsPublic API barrel; import from @aetherAssembly/core

Storage: The app uses two storage mechanisms:

  • IndexedDB (via the idb library): structured item data. All IndexedDB reads and writes are routed through InventoryService, which runs inside a dedicated Web Worker (via Comlink), keeping storage I/O off the main thread. Schema is version-gated:
    • v2: inventory, barcodeProfiles
    • v3: itemHistory
    • v4: wasteLog
  • localStorage (via BrowserLocalStorageAdapter): user preferences and sync state. Keys:
    • before-its-gone.settings: AppSettings (expiry window, custom locations, etc.)
    • before-its-gone.sync: SyncSettings (Supabase URL, anon key, lastSyncedAt)
    • before-its-gone.recipe-dismiss-{date}: recipe banner dismiss state
    • supabase.auth.token: Supabase session (managed by Supabase SDK)

No electron-store is used anywhere. All renderer-accessible state lives in the browser storage APIs above.

packages/ui

One component: InventoryCard. It renders a single inventory item with expiry status, quantity controls, nutrition chips, and tag display. Props are typed from packages/core/src/models.ts.

apps/web

Vite + React single-page application. Entry: src/main.tsx → src/App.tsx.

App.tsx owns:

  • All React state (inventory list, filter state, active tab, form state, banners)
  • IPC calls to Electron (wrapped behind window.beforeItsGone.* feature-detect guards)
  • Settings loading/saving via BrowserLocalStorageAdapter
  • The tab shell: Inventory, Shopping List, Waste Log, Data, Settings

SettingsPanel.tsx owns:

  • App preferences (expiry window, locations, notifications)
  • Email notification settings (form, snooze, test send)
  • Cloud sync (Supabase credentials, sign-in, sync-now)

SyncService.ts wraps @supabase/supabase-js with a simplified API (connect, signIn, signUp, sync). It is dynamically imported in App.tsx (on-demand at startup, only when sync credentials are configured) and statically imported in SettingsPanel.tsx (which is itself lazy-loaded). This keeps the ~202 kB Supabase bundle out of the initial JS chunk.

inventory.worker.ts hosts an InventoryService instance inside a dedicated Web Worker exposed via Comlink. inventoryWorkerService.ts exports a module-level singleton proxy; the interface is identical to the main-thread version so App.tsx uses it transparently. All IndexedDB reads and writes happen off the main thread.

main.tsx registers a service worker (/sw.js) on web builds (skipped in Electron) and captures the beforeinstallprompt event so App.tsx can surface a native Install button when the browser deems the PWA installable. In development it also dynamically imports @axe-core/react, which logs WCAG violations to the browser console after each render.

i18n.ts initialises i18next with the react-i18next plugin. String resources live in src/locales/<lang>.json; only English (en) ships currently. Components use the useTranslation hook (t('key')).

apps/electron

Electron main process. Not bundled by Vite; compiled by tsc to dist/, then loaded by electron-builder.

FileResponsibility
src/main.tsApp lifecycle, BrowserWindow, IPC handlers, auto-updater, digest scheduler
src/preload.tscontextBridge: the only bridge between renderer and main
src/scanner-server.tsSelf-signed HTTPS server (port 45678), Open Food Facts lookup, IPC notification
src/scanner-html.tsHTML/JS for the phone's camera + product review card page
src/scanner-middleware.tsRequest routing for the scanner server
src/email-service.tsreadEmailSettings, writeEmailSettings, sendEmail, DigestScheduler

IPC bridge

The renderer cannot access Node.js APIs directly. All privileged operations go through a strict bridge defined in preload.ts:

Renderer (React)
  └─► window.beforeItsGone.*   (contextBridge API)
        └─► ipcRenderer.invoke(channel, ...args)
              └─► ipcMain.handle(channel, handler)   (Electron main)
ChannelDirectionPurpose
app:versionR→MGet app version string
app:pingR→MHealth check
app:platformR→MGet process.platform
scanner:startR→MStart the phone scanner HTTPS server
scanner:stopR→MStop the scanner server
scanner:save-doneR→MNotify scanner server that item was saved
scanner:save-errorR→MNotify scanner server of save error
scanner:resultM→RPush scanned product data to renderer
updater:installR→MQuit and install downloaded update
updater:downloadR→MBegin update download
email:get-settingsR→MRead email-settings.json from userData
email:save-settingsR→MWrite email-settings.json to userData
email:sendR→MSend an email via the configured provider
email:digest-fireM→RTrigger renderer to build and send the digest

The renderer always guards IPC calls with if (window.beforeItsGone?.startScanner), so the web-only build degrades gracefully without throwing.

Phone scanner

Phone browser
  └─► HTTPS GET https://{local-ip}:45678/   (self-signed cert, stored in userData)
        └─► scanner-html.ts renders camera page
              └─► getUserMedia() → BarcodeDetector API
                    └─► POST /save  (barcode + product data)
                          └─► scanner-server.ts → ipcMain emits scanner:result
                                └─► Renderer receives, shows ScanModal

The self-signed certificate is generated once at first launch and stored in userData/ssl/. The QR code shown in ScanModal encodes the full HTTPS URL including the local IP address.

Open Food Facts lookup happens in scanner-server.ts (Node.js side) because the browser's Content Security Policy would block cross-origin fetches to world.openfoodfacts.org from the renderer in a packaged app.

Email pipeline

DigestScheduler (main process, setInterval 60s)
  └─► Checks time + digest settings from email-settings.json
        └─► Emits email:digest-fire IPC to renderer
              └─► App.tsx onDigestFire handler
                    └─► Queries IndexedDB for all items
                          └─► renderDigest() (email-templates.ts)
                                └─► Calls window.beforeItsGone.sendEmail(payload)
                                      └─► email:send IPC → email-service.ts
                                            └─► Resend SDK or nodemailer (SMTP)

Email credentials are stored in {userData}/email-settings.json, never in localStorage or IndexedDB. The file is written only by the Electron main process and is not accessible from the renderer directly.

Cloud sync

Supabase sync is handled entirely in the renderer (no IPC required; @supabase/supabase-js is a browser-compatible library):

SyncService.sync(localItems)
  ├─► UPSERT all local items → inventory_sync table
  │     (id, user_id, updated_at, data jsonb)
  └─► SELECT all remote items → merge by updatedAt (last-write-wins)
        └─► upsertInventoryItem() for items where remote is newer

The full InventoryItem is stored as a JSONB data column. This avoids camelCase-to-snake_case column mapping and means schema changes to InventoryItem don't require SQL migrations; only the initial table creation migration is needed.

Known limitation: deletions are not propagated. Items deleted on one device reappear after syncing from another. See Cloud Sync for the workaround.

Build pipeline

npm run build
  ├── build:packages   → tsc --build packages/core packages/ui  (incremental)
  ├── build:web        → vite build apps/web  (outputs to apps/web/dist/)
  └── build:electron   → tsc apps/electron/tsconfig.json  (outputs to apps/electron/dist/)

Packaging (electron-builder, triggered by npm run package:<platform>):

  • Bundles apps/web/dist/ as the renderer
  • Bundles apps/electron/dist/ as the main process
  • Produces platform artifacts in release/ (gitignored)

Auto-release (.github/workflows/release.yml):

  • Triggers on any v* tag push
  • Matrix build across ubuntu/macos/windows runners
  • Each runner calls electron-builder --publish always
  • Artifacts and update manifests (latest-linux.yml, latest-mac.yml, latest.yml) are attached to the GitHub release automatically
  • A post-build job downloads the Linux AppImages, computes SHA-256 checksums, fills them into the PKGBUILD template, and attaches the resulting PKGBUILD to the release for Arch Linux users
  • The publish-release job generates the full release notes (from CHANGELOG.md and computed artifact SHAs) and publishes the draft
  • npm packages (@aetherAssembly/core, @aetherAssembly/ui) are published to GitHub Packages on every tagged release

Key architectural decisions

Why no electron-store?

BrowserLocalStorageAdapter from packages/core already handles all key-value persistence in the renderer. Adding electron-store would require IPC round-trips for settings that don't need main-process access. The only file that legitimately needs to live outside the renderer's storage is email-settings.json (credentials shouldn't be in IndexedDB, which is easier to inspect).

Why JSONB for cloud sync instead of column-per-field?

Mapping every InventoryItem field to a typed SQL column requires a migration every time the model changes. Storing the item as data jsonb means the schema is stable; only id, user_id, and updated_at need to be indexed SQL columns.

Why does the digest scheduler live in main instead of a renderer setInterval?

The renderer can be hidden or garbage-collected on some platforms when the window is minimized. The main process runs for the lifetime of the app, making it reliable for background scheduling.

Why does Open Food Facts lookup happen in the scanner server (Node.js) rather than the renderer?

Packaged Electron apps apply a strict Content Security Policy. The renderer cannot make cross-origin fetch requests to arbitrary external APIs without relaxing CSP. The scanner server (already running in Node.js) proxies the lookup without any CSP constraint.

Last updated: 2026-10-04 AetherAssembly/Before-Its-Gone → License: GNU AGPLv3