~/jonasfink.dev
← cd ..

lightMe - Holistic Health App

2026 · Full-Stack Developer
ReactTypeScriptTailwind v4Node.jsExpress.jsMongoDB
lightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshotlightMe - Holistic Health App — screenshot

Situation

The fitness and nutrition app market is heavily fragmented — users juggle calorie counters, activity trackers, sleep diaries and paid coaching programs separately. For LightMe’s 12-week program, three core problems stood out. Nutrition data was fragmented and full of gaps: no single API reliably covers all foods, barcodes and custom creations, so searches failed or returned incomplete macros. Admins and coaches had no way to evaluate user progress (e.g. percentage weight loss, adherence) on a cohort basis. And the payment flows across Free, Trial, Basic and Premium tiers demanded a tamper-proof system that controls trials and survives cancelled payments without data loss.

Task

The MVP goal (deadline 16 May 2026) was a seamlessly integrated, high-performance full-stack web app. Functionally it needed an interactive food diary with live nutrient scaling and camera barcode lookup, a visual dashboard for weight, measurements, sleep and activity over freely chosen time ranges, and a 14-day, card-free Premium trial that is strictly once per user. The technical challenges: in Express 5, req.query is a read-only getter, so type coercions from validation libraries are lost by default and crash database aggregations; Stripe webhooks needed hardening against race conditions and duplicate deliveries; and the powerful admin dashboard could not be allowed to slow the initial load of the regular app.

Action

The frontend keeps the access token in volatile memory only; when it expires (15 min) an Axios interceptor catches the 401, silently refreshes via the httpOnly cookie (/auth/refresh) and retries the original request unnoticed. Changing a password clears the entire refreshTokens[] array in MongoDB, instantly invalidating every other session platform-wide. Food search runs through a cascading facade in nutritionService.ts — OpenFoodFacts → FatSecret (OAuth 1.0) → USDA FDC → custom ingredients — until it gets hits. The Stripe webhook is strict and ack-first: verify the signature on the raw body, write an idempotency marker (a unique eventId index; a duplicate-key error short-circuits with 200 OK), return received:true within ~100ms to avoid retries, and only then process the event asynchronously, deleting the marker on failure so Stripe’s retry can fire. On the frontend the whole admin dashboard is React.lazy-loaded so regular users never download it, monolithic pages were split into focused hooks and panels (the Food Diary page shrank from 991 to 278 lines), and nutrient scaling (scaleFood) is centralized so scanner, favorites and search all share one implementation.

Result

The cascading API approach plus a snapshot pattern for recipes (nutrient values are frozen when an item is added to a recipe) keeps the food diary consistent even if the underlying food is later edited or deleted. The card-free trial lowers the entry barrier massively — no payment details for 14 days of Premium — while a persistent hasUsedTrial flag on the user model blocks repeat abuse. The admin panel computes demographic and progress-based metrics (e.g. avgWeightLossPct, cohort nutrition averages), letting the operator tune the 12-week program from real data.

Key Takeaways

A few Express/Mongo pitfalls shaped the build. Because req.query is a getter in Express 5, delete and Object.assign silently fail and coerced Zod values never reach the controllers — solved by hard-redefining the whole query object with Object.defineProperty in a validateQuery middleware. Mongoose auto-casts string IDs to ObjectIds in normal queries but not inside aggregation $match, so pipelines returned empty arrays until IDs were cast explicitly with new mongoose.Types.ObjectId(userId). Missing optional values (steps, distance) turned whole weekly sums to null until $ifNull was applied consistently. And Express matched /recipes/:id before /recipes/range, reading "range" as an id — fixed by always registering static routes before dynamic ones.

© 2026 Jonas Fink