Engineering Deep Dive

Building MacroTracker: Architecture, Design Problems, and Solutions

By Sreehari · April 2026 · 20 min read

Back to Blog

01 Introduction & Motivation

I've been tracking macros on and off for a few weeks. Every app I used either locked core features behind a paywall, drowned me in ads, or required a PhD to log a simple meal. I wanted something minimal, fast, and mine — an app that respects the user's data, works offline, and doesn't gatekeep basic calorie tracking.

So I built MacroTracker — a cross-platform nutrition tracking app that runs as both an Android app and a Progressive Web App from a single codebase. It has AI-powered meal parsing, a 500,000+ food database via USDA, an in-app nutritionist assistant, beautiful themes, and zero ads. It's hosted at macrostracker.iamsreehari.in.

This post is a deep dive into the engineering behind it — the architecture decisions, the design problems I ran into, and how I solved them. If you're a senior engineer building cross-platform apps with local-first data or hybrid mobile stacks, there's something here for you.

02 Tech Stack & Why These Choices

Angular 20 Ionic 8 Capacitor 8 Node.js + Express MongoDB OpenAI GPT-4o-mini USDA FoodData Central Docker Chart.js Ionic Storage (IndexedDB)

Angular + Ionic was deliberate. I didn't need React's ecosystem here — I needed a framework with strong opinions about structure, dependency injection, lazy-loaded modules, and first-class mobile component support. Ionic gives you native-feeling UI components (tabs, modals, pull-to-refresh, gesture handlers) while Angular's DI system keeps the service layer clean.

Capacitor over Cordova was the obvious call. It gives you a clean bridge to native APIs (haptics, status bar, keyboard) without Cordova's plugin hell. More importantly, Capacitor apps serve from localhost on-device, meaning standard web APIs just work — no file:// protocol quirks.

MongoDB was chosen for the backend because the data is inherently document-oriented. Each user's daily log is a self-contained document with nested meals and food items — a perfect fit for MongoDB's document model. No joins, no ORMs, no impedance mismatch.

Chart.js for analytics because it's lightweight (~60KB gzipped), has excellent responsive behaviour, and plays well with Angular's change detection. I considered D3, but that's like bringing a fighter jet to a bike race for bar charts and doughnut rings.

03 System Architecture Overview


┌─────────────────────────────────────────────────────────────────────────┐
│                            CLIENT (Angular + Ionic)                     │
│                                                                         │
│  ┌──────────┐  ┌──────────┐  ┌─────────┐  ┌────────┐  ┌─────────────┐   │
│  │  Today   │  │ History  │  │ Charts  │  │ Goals  │  │  Settings   │   │
│  │  Page    │  │  Page    │  │  Page   │  │  Page  │  │   Page      │   │
│  └────┬─────┘  └────┬─────┘  └────┬────┘  └───┬────┘  └──────┬──────┘   │
│       │             │             │           │              │          │
│  ┌────┴─────────────┴─────────────┴───────────┴──────────────┴─────┐    │
│  │                    SERVICE LAYER                                │    │
│  │  ┌────────────┐ ┌─────────────┐ ┌──────────┐ ┌────────────────┐ │    │
│  │  │ MealService│ │SettingsServ.│ │ AiService│ │NutritionApiSvc││ │    │
│  │  └─────┬──────┘ └──────┬──────┘ └─────┬────┘ └───────┬────────┘ │    │
│  │        │               │              │              │          │    │
│  │  ┌─────┴───────────────┴──────────────┴──────────────┘          │    │
│  │  │           StorageService (Local-First Engine)                │    │
│  │  │   ┌─────────────┐         ┌─────────────────┐                │    │
│  │  │   │Ionic Storage│ ◄─────► │ REST API calls  │                │    │
│  │  │   │ (IndexedDB) │         │ (fire-and-sync) │                │    │
│  │  │   └─────────────┘         └────────┬────────┘                │    │
│  │  └────────────────────────────────────┼─────────────────────────│    │
│  └───────────────────────────────────────┼─────────────────────────┘    │
│                                          │                              │
└──────────────────────────────────────────┼──────────────────────────────┘
                                           │ HTTPS
┌──────────────────────────────────────────┼──────────────────────────────┐
│                         SERVER (Node.js + Express)                      │
│                                                                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  ┌────────────┐   │
│  │ Auth Routes  │  │ Storage API  │  │  AI Routes   │  │  Static    │   │
│  │ (OTP flow)   │  │ (KV CRUD)    │  │ (GPT proxy)  │  │  Assets    │   │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘  └────────────┘   │
│         │                 │                 │                           │
│  ┌──────┴─────────────────┴─────────────────┘                           │
│  │                    MongoDB                                           │
│  │   ┌──────────┐  ┌──────────┐  ┌───────────┐                          │
│  │   │ kv_store │  │otp_store │  │chat_store │                          │
│  │   └──────────┘  └──────────┘  └───────────┘                          │
│  └──────────────────────────────────────────────────────────────────────│
│                                                                         │
│  External: OpenAI API  ·  USDA FoodData Central API  ·  SMTP (email)    │
└─────────────────────────────────────────────────────────────────────────┘

The architecture is intentionally simple. The client is the smart half — it owns data modelling, business logic, validation, and state management. The server is deliberately dumb: it's a key-value store proxy, an OTP mailer, and an OpenAI relay. This asymmetry is a feature, not a bug.

04 Data Modeling: Client-Owned, Server-Agnostic

One of the most unusual decisions in this project is that the server has no knowledge of the data schema. The backend is a generic key-value store — it accepts any JSON blob under a composite key of userId:key and stores it verbatim. All data structure is defined and enforced on the client.

The Model Layer

Everything lives in a single nutrition.model.ts file — interfaces, types, constants, and utility functions:

export interface Macros {
  calories: number;
  protein: number;
  carbs: number;
  fat: number;
  fiber?: number;
  sugar?: number;
  sodium?: number;
}

export interface FoodItem {
  id: string;
  name: string;
  servingSize: number;
  servingUnit: string;
  macros: Macros;
  autoFetched: boolean;   // true = from USDA, false = manual entry
}

export interface Meal {
  id: string;
  name: string;
  icon: string;
  items: FoodItem[];
  completed: boolean;
  time?: string;
}

export interface DailyLog {
  date: string;           // YYYY-MM-DD
  meals: Meal[];
  waterIntake: number;    // liters
  notes?: string;
}

Why Client-Owned Data?

Design Insight

A generic KV store means the server is schema-agnostic. I can evolve the client's data model — add fields, restructure meals, introduce new entities — without touching the backend at all. No migrations, no API versioning, no coordinated deploys. The server simply stores and returns whatever the client gives it.

The trade-off is real: you lose server-side validation, querying, and aggregation. But for a personal nutrition tracker, the client is the authority on data shape. The server's only job is durability and cross-device sync. This trade-off pays off massively in development velocity.

Storage Key Convention

Key Type Description
daily_log_YYYY-MM-DDDailyCurrent day's meals + water intake
goalsUserMacro & calorie targets
profileUserName, age, weight, height, activity level
settingsUserTheme, unit preferences
chat_historyUserAI conversation history (capped at 100 msgs)
food_cache_${"{name}"}CacheCached USDA lookup results

On the server, these become composite keys like sree@email.com:daily_log_2026-04-04 in a single MongoDB collection with a unique index on the key field. Simple, predictable, and zero ORM overhead.

05 The Local-First Sync Engine

This is the heart of MacroTracker's reliability story. The StorageService implements a local-first, cloud-sync storage engine that ensures the app works offline and data is never lost.

Write Path: Local First, Then Sync

async set(key: string, value: any): Promise<void> {
  // Always write to local first — data is never lost
  await this.initLocal();
  await this._storage!.set(key, value);

  // Then push to server (fire-and-forget on failure)
  const userId = this.getUserId();
  if (userId) {
    try {
      await firstValueFrom(
        this.http.put(`${this.apiUrl}/storage/${userId}/${key}`, { value })
      );
      this.serverReachable$.next(true);
    } catch (err: any) {
      if (err?.status === 0) this.serverReachable$.next(false);
      console.warn(`[Storage] DB set failed, data saved locally only:`, err);
    }
  }
}

Every write goes to Ionic Storage (IndexedDB) first. Only after local persistence is confirmed does it attempt the HTTP PUT to the server. If the server is down, the data is safe locally and the user sees an offline toast. No data loss, no blocking writes.

Read Path: Server Wins, Local Fallback

async get<T>(key: string): Promise<T | null> {
  const userId = this.getUserId();
  if (userId) {
    try {
      const res = await firstValueFrom(
        this.http.get<{ value: T }>(`${this.apiUrl}/storage/${userId}/${key}`)
      );
      this.serverReachable$.next(true);
      // Refresh local cache so offline reads stay current
      await this._storage!.set(key, res.value);
      return res.value;
    } catch (err: any) {
      if (err?.status === 0) this.serverReachable$.next(false);
    }
  }
  // Fallback: read from local
  return this._storage!.get(key);
}

Reads try the server first. If successful, the local cache is refreshed so that future offline reads return the latest data. If the server is unreachable (status 0), the app seamlessly falls back to IndexedDB. The user may not even notice they're offline.

Connectivity State

The serverReachable$ BehaviorSubject emits connectivity state based on actual HTTP results — not navigator.onLine, which is famously unreliable. When the server returns a status of 0 (network error), the flag flips to false, triggering a persistent offline banner. When a subsequent request succeeds, it flips back to true.

Problem

A user could be on Wi-Fi behind a captive portal — navigator.onLine says true, but every API call fails silently. The offline banner never shows, writes disappear into the void.

Solution (Trade-off Accepted)

Derive connectivity exclusively from real HTTP outcomes. Status 0 = unreachable; any 2xx/4xx = reachable. Accept the minor latency of discovering offline state on the first failed call rather than polling.

06 Passwordless Authentication

MacroTracker uses passwordless, OTP-based email authentication. No passwords, no OAuth, no tokens.

The Flow

  1. User enters their email on the login screen
  2. Server generates a 6-digit OTP, stores it in MongoDB with a 10-minute TTL, and emails it via SMTP (Nodemailer)
  3. User enters the OTP — server validates, deletes the OTP, and returns success
  4. Client stores the email as the user's identity — that's it. No JWT, no session token
Design Decision: No Tokens

The app's security model is straightforward: you know the email, you can access the data. Since all data is user-specific and non-sensitive (nutrition logs), a JWT adds complexity without meaningful security uplift. No JWTs means no token refresh, no token expiry edge cases, and no logout bugs.

Rate Limiting & Security

// Rate-limit: block repeated OTP requests within 60 seconds
const recent = await db.collection(OTP_COLLECTION).findOne({
  email: normalizedEmail,
  createdAt: { $gt: new Date(Date.now() - 60 * 1000) },
});
if (recent) {
  return res.status(429).json({ error: 'Please wait before requesting another OTP.' });
}

OTPs are rate-limited to one per 60 seconds per email. MongoDB's TTL index auto-deletes expired OTPs — no cron jobs, no manual cleanup. The OTP is consumed (deleted) immediately after successful verification, so replay attacks aren't possible.

Demo Account for App Store Review

Google Play requires a test login for app review. Instead of building a separate auth bypass, I added a simple environment variable check:

const DEMO_EMAIL = process.env.DEMO_EMAIL;
const DEMO_OTP   = process.env.DEMO_OTP;

// In send-otp handler:
if (normalizedEmail === DEMO_EMAIL) {
  return res.json({ success: true }); // Skip email, accept fixed OTP
}

Minimal code, clean separation, and the demo credentials live only in the server's environment — never in the client bundle.

07 AI Integration: Milo & Recipe Parsing

There are two AI features in MacroTracker, both powered by OpenAI's GPT-4o-mini and proxied through the backend to keep API keys server-side.

Recipe Parsing

The "AI" tab in the Add Food screen lets you describe a meal in natural language — "2 eggs scrambled with butter, 2 slices of whole wheat toast, and a glass of orange juice" — and get back structured food items with full macro breakdowns.

// System prompt for recipe parsing
{
  role: 'system',
  content: `You are a nutrition expert. Parse the user's recipe/food description
    into individual food items with accurate macros. Return ONLY valid JSON array.
    Each item must have: name, servingSize, servingUnit, calories, protein, carbs,
    fat, fiber, sugar, sodium. Be accurate with Indian foods, common recipes,
    and standard nutritional values.`
}

The response is parsed from JSON (with markdown fence stripping for robustness), and each item is displayed for the user to review, edit, and accept. The autoFetched flag on each FoodItem is set to true so the UI can visually distinguish AI-estimated items from manual entries.

Milo: The Context-Aware Nutritionist

Milo is the in-app AI assistant. What makes it useful is context injection — every chat message sends the user's profile, macro goals, and today's logged meals as part of the system prompt:

const systemPrompt = `You are Milo, a friendly AI nutrition assistant.

User profile:
  Name: Sree, Age: 27, Weight: 75 kg, Height: 178 cm
  Activity level: moderate

User goals:
  Calorie goal: 2000 kcal, Protein goal: 150g

Today's data:
  Calories logged: 1420 kcal, Protein logged: 98g
  Meals logged: Breakfast, Lunch
  Water today: 1.5L

Guidelines:
- Give personalized answers based on the user's data above
- Calculate BMI, TDEE, macro ratios on request
- Keep responses concise (2-4 sentences)`;

This means Milo can answer "How much protein do I still need today?" or "What should I eat for dinner to hit my goals?" with actual personalized numbers — no generic advice.

Conversations are persisted in MongoDB (capped at 100 messages per user), with the last 20 messages sent as context to maintain continuity. The chat UI is a custom component with a markdown-to-HTML pipe that handles bold text and line breaks.

08 Cross-Platform: One Codebase, Two Targets

MacroTracker ships as both an Android APK (via Capacitor) and a Progressive Web App served from the same server. The challenge is that these targets have different base URLs and output requirements.

The Build Split

TargetOutput dirbaseHrefPurpose
Web (PWA)www-web//web-app/Browser access from the server
Androidwww//Capacitor native shell

The web build outputs to www-web/ with baseHref="/web-app/" because the server serves the Angular app under the /web-app/ subpath — the root (/) is reserved for the marketing landing page. The Android build outputs to www/ with baseHref="/" because Capacitor serves assets from the device's local file system.

Platform-Aware Components

// AppBannerComponent — only shown on web, not in native shell
import { Capacitor } from '@capacitor/core';

showBanner = !Capacitor.isNativePlatform();

The AppBannerComponent displays a "Download the Android app" CTA when running in a browser, but hides itself when running inside the Capacitor native shell. One Capacitor.isNativePlatform() check — no environment variables, no build-time flags.

Server-Side Routing Fallback

// Catch-all for Angular deep links under /web-app/
app.get('/web-app/*', (req, res) => {
  res.sendFile(path.join(__dirname, 'www/index.html'));
});

The Express server has a catch-all route that serves index.html for any request under /web-app/*. This enables client-side routing — without it, refreshing /web-app/tabs/charts would return a 404 from Express.

09 Theming System & Glassmorphism

MacroTracker ships with five full themes, each providing a complete visual overhaul — not just accent color swaps, but full background, card, border, shadow, and gradient redefinitions.

ThemePrimaryAccentFeel
Midnight (default)Dark navyVioletFocused, minimal dark
AuroraDeep blueEmerald greenNorthern lights energy
EmberDark charcoalWarm orangeCosy evening warmth
OceanDeep navyTealCalm, deep-water cool
DawnLight greyIndigo blueClean minimal light

Implementation

Each theme is a CSS class applied to <body> (e.g. theme-aurora). Inside each class, all Ionic CSS custom properties are overridden — --ion-background-color, --ion-card-background, --ion-text-color, plus custom properties like --theme-gradient and --theme-glow.

The theme class is persisted in localStorage and applied before Angular bootstraps, so there's no flash of the default theme. The SettingsService reads the stored theme and toggles document.body.classList during initialization.

Glassmorphism Design Language

The entire UI uses a glassmorphic design language: translucent cards with backdrop-filter: blur(), subtle borders, and glow effects. The tab bar, ion-lists, and food item cards all use this treatment:

ion-card {
  background: rgba(255, 255, 255, 0.04);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 20px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}

Buttons use the theme gradient for solid CTAs and a glassmorphic outline treatment for secondary actions. The Urbanist font rounds out the modern feel.

10 Design Problems & Solutions

Here are the most interesting problems I ran into during development and how I solved them.

Problem 1: Android WebView Clears localStorage

Problem

Android WebView can clear localStorage under memory pressure. On a cold start after this, the user's email was gone and they appeared logged out — even though their server-side data was intact. One user reported being "logged out randomly" and I couldn't reproduce it on desktop.

Solution

Store the user's email redundantly in both localStorage and Ionic Storage (IndexedDB). On app init, AuthService.restoreEmail() re-hydrates localStorage from IndexedDB if the key is missing.

async restoreEmail(): Promise<void> {
  await this.initLocal();
  const stored = await this._storage?.get(USER_EMAIL_KEY);
  if (stored && !localStorage.getItem(USER_EMAIL_KEY)) {
    localStorage.setItem(USER_EMAIL_KEY, stored);
  }
}

Problem 2: navigator.onLine Is Unreliable

Problem

navigator.onLine returns true even when the device has LAN access but no actual internet (e.g. captive portal Wi-Fi). This caused the app to attempt API calls that were guaranteed to fail without ever triggering the offline banner.

Solution

Derive connectivity from real HTTP results exclusively. HTTP status 0 = network unreachable. The serverReachable$ BehaviorSubject drives the offline banner — no polling, no navigator.onLine listeners.

Problem 3: Data Migration from Local-Only to Cloud

Problem

Early versions stored everything in localStorage only. When I shipped cloud sync, existing users had months of data that had never been pushed to the server — logging in on a second device showed an empty app.

Solution

On first login after the upgrade, the app reads all local keys matching known patterns and replays them to the server. A local migrationDone flag prevents re-running. Runs silently in the background — users never see it.

Problem 4: GPT Returns Markdown-Wrapped JSON

Problem

Despite a system prompt saying "Return ONLY valid JSON", GPT-4o-mini frequently wrapped responses in ```json ... ``` markdown fences. Calling JSON.parse() directly would throw and the whole meal-parsing flow would crash.

Solution

Strip markdown fences before parsing. A simple regex handles all observed formats. Never trust an LLM to follow formatting instructions perfectly — always sanitize.

const content = data.choices[0].message.content.trim();
const jsonStr = content.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
const items = JSON.parse(jsonStr);

Problem 5: Water Intake Debouncing

Problem

The water counter has + and - buttons. Users tap them quickly. Each tap fired an HTTP PUT, creating a burst of 8–10 requests per second — hammering the server and causing out-of-order responses that corrupted the displayed value.

Solution

Update the UI immediately for instant feedback, but debounce the persistence call by 800 ms. Rapid taps collapse into a single write. Applied to all counter inputs throughout the app.

Problem 6: Serving Both a Landing Page and a Web App

Problem

The root URL / needed to serve a marketing landing page while /web-app/ served the Angular SPA. Using express.static naively at root would serve the wrong index.html for deep-link Angular routes.

Solution

Register routes explicitly before the static middleware. Landing page gets a dedicated GET / route. The Angular build is mounted at /web-app/ via a scoped express.static(). The Angular catch-all wildcard is registered last so it only fires for unmatched Angular routes.

// Static assets for the Angular app
app.use('/web-app', express.static(path.join(__dirname, 'www')));

// Marketing landing page at root
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'views/index.html'));
});

// Angular deep link catch-all (only under /web-app/)
app.get('/web-app/*', (req, res) => {
  res.sendFile(path.join(__dirname, 'www/index.html'));
});

Route order matters. The static middleware fires first for known files (.js, .css, .svg). The wildcard only catches URLs that don't match an actual file — which are Angular routes.

11 Deployment Pipeline

Deployment is a single command: npm run deploy. It runs a PowerShell script that handles everything:

# deploy.ps1 — four steps to production

[1/4] Build Angular app          →  npm run build → www-web/
[2/4] Build Docker image         →  docker build --platform linux/arm64
[3/4] Push to Docker Hub         →  docker push r151149/macro-tracker:latest
[4/4] Deploy on server           →  SCP compose file + SSH pull & restart

Docker Architecture

The Docker image is built for linux/arm64 (the server runs on an ARM VM). The Dockerfile uses Node 22 Alpine, copies the server code and the prebuilt www-web/ static assets. A single container serves both the API and the web app.

Docker Compose runs two containers: the API container (port 2727) and MongoDB 7 with a persistent volume. An external Nginx reverse proxy terminates TLS and forwards requests to port 2727.

The deploy script uses environment variables for all configuration (DEPLOY_DOCKER_IMAGE, DEPLOY_SERVER, DEPLOY_SSH_KEY, etc.), making it portable across machines and CI systems. The -SkipBuild flag lets you redeploy server-only changes without rebuilding the Angular app.

12 Testing Strategy

Tests use Jasmine + Karma with ChromeHeadless. The model layer has the highest coverage — pure functions like calculateMealMacros(), calculateDailyMacros(), and generateId() are easy to test and critical to get right.

The StorageService is tested with mocked HTTP responses to verify the local-first/cloud-sync behaviour — specifically that writes always persist locally even when the server returns an error, and that reads properly fall back to IndexedDB when the server is unreachable.

Page-level tests verify component initialisation and basic rendering. The MealService tests validate CRUD operations with a mocked StorageService. The overall philosophy is: test the engine, trust the chassis — spend testing effort on services and models, not on Ionic component rendering.

13 Lessons Learned

1. Local-first isn't free, but it's worth it

Building a local-first sync engine is more work than just calling an API. You have to think about write ordering, fallback reads, cache invalidation, and connectivity detection. But the payoff is huge — the app feels instant, works offline, and users never see loading spinners for their own data.

2. A dumb server is a fast server

By making the server a generic KV store, I eliminated an entire class of backend work — migrations, API versioning, validation schemas, and coordinated deploys. The server has been deployed once and hasn't needed a code change since. All feature development happens on the client.

3. Never trust navigator.onLine

It lies. Derive connectivity from real HTTP results. If you ping and get a 200, you're online. If you get a network error, you're not. That's it.

4. Store identity redundantly on Android

Android WebView's localStorage can be cleared by the OS. If your app's identity depends on localStorage, redundantly store it in IndexedDB, SecureStorage, or both. This cost me a real user session before I figured it out.

5. Always sanitize LLM output

GPT will wrap JSON in markdown fences, add "Here's the JSON:" prefixes, and occasionally return malformed data. Always strip, sanitize, and handle parse errors. Never JSON.parse() raw LLM output without a try-catch and cleanup.

6. One Docker image keeps deploys simple

Serving the API, web app, and landing page from a single container means one image to build, push, and deploy. No separate CDN, no static host, no multi-container orchestration. For a solo project, simplicity wins every time.

7. Debounce interactions that trigger persistence

Any UI element the user can tap rapidly should debounce its persistence calls. Let the UI update instantly for responsiveness, but batch the actual writes. Your server and database will thank you.

14 What's Next

  • Barcode scanner — Use the device camera to scan food barcodes and auto-fetch nutrition data from OpenFoodFacts
  • iOS build — Capacitor supports iOS natively; just need to configure the Xcode project and provisioning
  • Offline sync queue — An outbox pattern that queues writes made offline and replays them when connectivity returns
  • Shared meal plans — Generate a shareable link for a day's meal plan
  • Widget — Android home screen widget showing today's macro progress