CaloVoice: Voice-First Calorie Tracker
A PWA that turns natural speech into structured food logs via a multi-stage LLM pipeline, reducing meal logging from ~2 minutes to under 10 seconds.
Problem
Calorie tracking apps require 2–3 minutes per meal: searching databases, scrolling variants, adjusting servings. Most users quit within two weeks. The friction of logging outweighs the perceived value before any habit forms.
Solution
Voice-first logging: tap, speak, done. A multi-stage LLM pipeline converts natural speech into structured food entries in under 10 seconds, with non-blocking fallbacks at every stage so the user never hits a dead-end.
Overview
A voice-powered calorie tracker built as a mobile-first PWA. The engineering challenge: build a reliable, low-latency pipeline from raw speech to structured nutritional data (with graceful fallbacks at every stage) while keeping the frontend snappy with zero animation libraries, zero state management libraries, and zero routing libraries.
Voice Pipeline
State Architecture
No Redux, no Zustand. Firestore real-time listeners are the state layer: onSnapshot subscriptions mean the UI is always in sync with no manual cache invalidation or refetching.
Derived state chain: useDayLog → useCalorieSummary → useInsightText → usePredictiveInsight → useTimeline. Zero instances of the useEffect(() => setDerived(compute(source)), [source]) anti-pattern.
Ref-based async safety: Voice hooks use refs (onReadyRef, transcriptRef, kindRef) so async callbacks from speech events and setTimeout always read the latest values without triggering re-renders or re-subscriptions.
Transaction writes: Every Firestore write uses runTransaction to prevent read-modify-write races when multiple food items save in rapid succession.
Design System Enforcement
Design token violations are a build error, not a code review comment. Two custom ESLint rules make it impossible to ship raw values to production.
Animation System
Pure CSS throughout: no Framer Motion, no GSAP, no React Spring. Zero JS runtime cost for any animation in the app.
- Sheet exit-before-unmount: a
useSheetAnimationhook toggles a CSS exit class, then waits 240ms before callingonClose. A ref guard prevents double-fire from rapid taps. The parent never unmounts the sheet until the exit animation completes. - Animated numbers: use a
requestAnimationFrameloop with ease-out cubic easing over 500ms.setDisplayonly fires when the rounded value actually changes: prevents spamming React with identical updates every frame. - Spring curves throughout: sheet entrance uses
cubic-bezier(0.22, 1, 0.36, 1)for an overshoot feel.prefers-reduced-motioncollapses all durations to0.01msglobally.
Lessons Learned
The AudioContext and the silence-detection timer serve different purposes: one visualizes, one triggers. Merging them into a single audio-processing loop would have made both harder to reason about and harder to test. Let the platform's own primitives stay separate.
Checking signal.aborted after every await in a multi-step async pipeline is what prevents stale results from surfacing. One missed check is enough for an old parse to flip isProcessing off while a new one is running, a race that is invisible in testing and fatal in production.
Syncing derived state in effects creates invisible timing dependencies between renders. The hook chain (useDayLog → useCalorieSummary → usePredictiveInsight) is synchronous, predictable, and testable. Effects would have introduced the same class of bugs the Firebase listener was meant to eliminate.
No-raw-style-literals and no-color-literals enforced design token usage without relying on reviewer attention or discipline. Once internalized, the constraint becomes invisible. A build error is permanent; a code review comment is not.
Tech Stack: React 19 · TypeScript · Vite · Firebase · OpenRouter · CalorieNinjas API · Web Speech API · Zod · React Hook Form · MUI · PWA
