Building MacroTracker: Architecture, Design Problems, and Solutions
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 + 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?
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-DD | Daily | Current day's meals + water intake |
goals | User | Macro & calorie targets |
profile | User | Name, age, weight, height, activity level |
settings | User | Theme, unit preferences |
chat_history | User | AI conversation history (capped at 100 msgs) |
food_cache_${"{name}"} | Cache | Cached 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.
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.
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
- User enters their email on the login screen
- Server generates a 6-digit OTP, stores it in MongoDB with a 10-minute TTL, and emails it via SMTP (Nodemailer)
- User enters the OTP — server validates, deletes the OTP, and returns success
- Client stores the email as the user's identity — that's it. No JWT, no session token
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
| Target | Output dir | baseHref | Purpose |
|---|---|---|---|
| Web (PWA) | www-web/ | /web-app/ | Browser access from the server |
| Android | www/ | / | 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.
| Theme | Primary | Accent | Feel |
|---|---|---|---|
| Midnight (default) | Dark navy | Violet | Focused, minimal dark |
| Aurora | Deep blue | Emerald green | Northern lights energy |
| Ember | Dark charcoal | Warm orange | Cosy evening warmth |
| Ocean | Deep navy | Teal | Calm, deep-water cool |
| Dawn | Light grey | Indigo blue | Clean 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
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.
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
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.
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
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.
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
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.
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
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.
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
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.
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