Foodprint
"Know your food footprint."
Table of Contents
What it is
Swiggy and Zomato give you a scrollable list of past orders. That's it. No monthly total, no trend line, no breakdown of how much of your money went to food versus delivery fees, packing, platform fees, and GST. After a year of ordering, you have a feeling you're spending a lot, but no number.
Foodprint turns your own Swiggy and Zomato order history into real analytics: spending trends, ordering patterns, the true cost of fees and GST, and suggestions to spend less. Everything runs locally. Your orders never leave your machine.
Two constraints shaped every decision: privacy first (no servers, no accounts, no telemetry) and use the user's own session (no passwords, no OTP automation, no scraping someone else's account). If you can see your orders in your browser, Foodprint should be able to read them from your logged-in session.
How it works
The insight that makes capture possible: the order-history endpoints the Swiggy and Zomato web apps call are same-origin endpoints. If you run code inside the logged-in page, a plain fetch() uses the real session cookies and the WAF token the browser already solved. No auth plumbing required.
The same framework-agnostic capture core runs inside an embedded Chromium window in the desktop app, and inside a content script in the browser extension. Same analytics, same database, same core: two hosts.
The Electron flow in detail: main owns the window and the persistent session, while the injected core does the fetching because it's the only thing that shares the page's session.
sequenceDiagram
autonumber
participant App as App window (React)
participant Main as Electron main (Node)
participant CW as Capture window (Chromium)
participant Inject as Injected capture core
App->>Main: ipc capture:run(source)
Main->>CW: open persist:swiggy with Chrome UA
Main->>Inject: executeJavaScript(capture-inject.js)
Inject->>Inject: same-origin fetch (cookies + WAF token)
Inject-->>CW: postMessage progress
CW-->>Main: preload-capture then ipc capture:message
Main-->>App: capture:progress
Inject-->>Main: CaptureResult
Main->>CW: close on success
Main-->>App: result then Dexie upsert
Progress streams back from the page: postMessage → preload-capture → IPC → ipc.ts → the app. Orders then normalize and upsert into Dexie through the same import pipeline the web app already used, and the capture window closes itself.
Screenshots
Architecture Decisions
Why isolate the capture core from the host?
> The unofficial endpoints are the unstable piece. Keep the risky layer independent and the rest of the product can evolve around it.
src/capture/ knows nothing about React, the extension, or Electron. It exposes a function that takes an onProgress callback and returns normalized orders. That one decision is why the later migration to Electron was a weekend of work instead of a rewrite: a browser extension, an Electron main world, and tests all share one implementation.
Why a browser extension first, then Electron?
> The extension validated the idea fastest. Electron fixed the host without touching the core.
The web app + extension flow had fatal friction: two installs, manifest.json origins to whitelist for every dev and production origin, and a session living in a browser the app didn't control. Electron gave us one install, a real Chromium window we own, and a persistent session we manage (persist:swiggy / persist:zomato) so you sign in once and stay signed in.
> Tradeoff: the web app and extension still work, but the desktop app is the recommended path.
Why capture same-origin inside the logged-in page?
> A plain fetch() in the page uses the real session cookies and the WAF token the browser already solved. No auth plumbing.
electron/src/capture/inject.ts is bundled to a browser IIFE and injected into the page's main world via executeJavaScript. It calls the site's own endpoint same-origin. Cookies live in Electron's partitioned storage and are never read by the app: the capture script fetches from inside the page.
Why one normalized order and an idempotent upsert?
> Every source funnels into one shape with id = `${source}:${orderId}`, so re-running a capture is always safe.
Live capture, Swiggy data export, generic JSON/CSV, and sample data all flow through one constructor, buildOrder(), into importOrders(), which upserts instead of duplicating. That single decision removed an entire class of "I imported twice and my numbers are wrong" bugs.
flowchart LR
L["Live capture"] --> BO["buildOrder()"]
SE["Swiggy data export"] --> BO
J["Generic JSON / CSV"] --> BO
S["Sample data"] --> BO
BO --> N["NormalizedOrder"]
N --> UP["importOrders(): upsert by source:orderId"]
UP --> D[("IndexedDB")] Why reverse-engineer the money model?
> The analytics are only as good as the bill. The normalized fields reconcile to the rupee.
Swiggy fields like item_total, order_tax, order_delivery_charge, charges["Packing Charges"], convenience_fees, and order_discount map onto a clean shape where itemTotal + fees + platform fee (with tax) + tax − discount ≈ orderTotal. The Financial page can therefore show each charge as a percentage of order value without hand-waving.
Discounts (coupon/trade discounts that reduced the bill) are carefully separated from savings (membership perks like Swiggy One that were never charged). Conflating them would overstate how much the user actually saved.
Why a custom app:// protocol?
> Packaged apps can't use file:// for a single-page React app without breaking routing, and file:// origins are a poor home for IndexedDB.
In production we register a custom, privileged app:// scheme and serve the built renderer over app://spend/index.html, with an SPA fallback that serves index.html for unknown routes. That gives a stable, secure origin for routing and keeps IndexedDB isolated and durable across updates.
Why secure-by-default Electron settings?
> Privacy is an architecture, not a feature.
contextIsolation: true, sandbox: true, nodeIntegration: false, and minimal preload bridges. The capture window also overrides the user-agent to a standard Chrome UA so there's no "Electron" tell. If you try to close the window mid-capture, a native dialog asks you to confirm first; if capture fails (say, you're not signed in), the window stays open so you can sign in and retry.
Why local-first with no servers?
> No servers, no accounts, no telemetry. The data is yours and stays on your device.
Dexie over IndexedDB is the only store. There is no backend to deploy, no auth to manage, and no data to leak. The cost is that data only exists on one machine, which is the accepted tradeoff for a tool that reads your own order history.
Features
- Two capture backends:live capture in the desktop app, or a browser extension for the web app, both on one framework-agnostic core
- Flexible imports:Swiggy data export, generic JSON/CSV, and a sample-data generator as fallbacks when a capture path is blocked
- Normalized order model:one shape built by
buildOrder(), upserted by`${source}:${orderId}`so imports are idempotent and re-runnable - Reconciling money model:food, delivery, packing, platform fee, platform fee tax, GST, discounts, and savings tracked separately
- Five analytics surfaces:Overview, Trends, Patterns, Financial, Suggestions
- Orders table:searchable, filterable by date range and platform, exportable to CSV
- Budget and predictions:projected month-end, safe daily spend, and a next-order prediction
- Local-only storage:Dexie over IndexedDB, no servers, no accounts, no telemetry
The Analytics Surfaces
The five pages turn a normalized order into answers. Trends handles when and how much; Patterns handles when and what; Financial handles where the money goes; Suggestions handles what to do about it.
- Overview:this month at a glance: spend, average order, savings, and fees paid, plus data-source status.
- Trends:spend over time by month, year, or all-time, with period-over-period deltas and a breakdown table.
- Patterns:a day×hour heatmap, spend by hour and weekday, ordering cadence, streaks, late-night/weekend shares, most-ordered cuisine and restaurant, and unusually large orders.
- Financial:food vs delivery vs packing vs platform fee vs GST vs discounts, cost mix by platform, and fee-creep ratios over time.
- Suggestions:a monthly budget with projected month-end, safe daily spend, a next-order prediction, and concrete ways to spend less with estimated savings.
From Idea to Evolution
The project moved through three distinct phases. The throughline is that the risky, unstable layer (capture) was isolated first, so the rest of the product could evolve around it without a rewrite.
timeline
title Foodprint: Idea to Execution to Evolution
section 1. Idea
Question : Where did my food money go?
Constraints : Local-first, use my own session
Proof : Same-origin fetch from a logged-in tab
section 2. Execution
Web app : React, IndexedDB, Dexie
Extension : Capture in the logged-in tab
Analytics : Trends, Patterns, Financial, Suggestions
section 3. Evolution
Electron : One install, owned session
Capture window : Persistent partition, injected core
Desktop polish : custom app protocol, splash, packaging Phase 1: Idea
A single question and two constraints: show me my food spending, locally, using my own session. The first working artifact was a same-origin fetch against an order-history endpoint from a logged-in tab. That proved the core.
Phase 2: Execution (web app + extension)
The capture core was wrapped in a React app with IndexedDB and shipped with a Chrome extension as the host. This validated the analytics (Overview, Trends, Patterns, Financial, Suggestions) and taught the first hard lesson: the capture was the hard part, not the charts.
Phase 3: Evolution (Electron desktop app)
The core stayed; the host was replaced. The desktop app gave one install, a managed persistent session, a window lifecycle under our control, auto-close, and a custom app:// origin. Because the capture core, pipeline, and database were never host-specific, the migration was an addition, not a rewrite.
Tech Stack
- Desktop shell: Electron with a custom privileged
app://protocol and partitioned persistent sessions - UI: React 19, TypeScript, Vite, Tailwind CSS
- Storage: Dexie over IndexedDB: 100% local, no backend
- Charts: Recharts
- Capture core: framework-agnostic TypeScript (
src/capture/), bundled to an IIFE for injection - Web host: browser extension (Chrome/Edge) sharing the same capture core
- Packaging: electron-builder (dmg / nsis / AppImage / deb)
Key Numbers
- 2 hosts:Electron desktop app and browser extension, one capture core
- 5 analytics surfaces:Overview, Trends, Patterns, Financial, Suggestions
- 4 import sources:live capture, Swiggy export, JSON/CSV, sample data
- 2 persistent sessions:
persist:swiggyandpersist:zomato, sign in once - 1 normalized order:one shape, upserted by
`${source}:${orderId}` - 0 servers:no accounts, no telemetry, data never leaves the machine
What I learned
- Isolate the risky part. The unofficial endpoints are the unstable piece; keeping
src/capture/framework-agnostic meant a browser extension, an Electron main world, and tests could all share one implementation. - One shape, one constructor. A single normalized order and an idempotent upsert removed whole categories of bugs.
- The host matters as much as the core. The extension's friction wasn't in the capture logic: it was in the install, the origins, and the window lifecycle.
- Privacy is an architecture, not a feature. No servers, partitioned sessions, sandboxed preloads, and
app://are what let me say "your orders never leave your machine" and mean it. - Provide fallbacks. Unofficial APIs break, so data exports, generic JSON/CSV, and sample data keep the app useful even when a capture path is blocked.