Skip to main content
Case StudiesProjectsAbout
Case StudiesProjectsAbout
Project

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.

5 min read
React 19TypeScriptFirebaseLLM / AIVoice UXPWA
GitHubLive Demo
<10s
Voice to log
vs. ~2 min manual
0
JS dependencies
for state, animation, routing
4
Fallback stages
never a dead-end error
My Role
Solo: product, design, engineering, deployment
Timeline
2025
Team
1 engineer
Impact
Meal logging from ~2 minutes to under 10 seconds
Platform
Mobile PWA
Stack
React 19 · Firebase · OpenRouter · Web Speech API
Status
Shipped · Live

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.

CaloVoice dashboard: calorie ring with pace indicator and today's meal log
Main dashboard: calorie ring with pace indicator, today's meal log, and predictive insight.

Voice Pipeline

speech-pipeline.ts

Stage 1, Capture: Web Speech API captures speech continuously. Silence detection is transcript-driven: a 3.5s timer resets on transcript changes, not on audio amplitude. AudioContext runs in parallel for waveform visualization only. These are intentionally separate concerns.

Stage 2, Abort safety: Each parse() call creates a new AbortController and cancels the previous in-flight request. After every await, the code checks signal.aborted to discard stale results. The finally block only clears isProcessing if the request wasn't aborted, preventing a race where an old parse flips loading off while a new one is running.

Stage 3, LLM extraction: OpenRouter with JSON mode. Zod validates the response shape. Food items resolve via Promise.all with abort checks between each item. Falls back to a local regex parser for activity logging when the LLM is unavailable.

Stage 4, Fallback chain: unsupported browser → manual form, empty transcript → manual form, LLM failure → manual form (food) or local parser (activity). The user never hits a dead-end error screen.

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

◆Enforcement Philosophy

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.

.eslintrc: custom rules

no-raw-style-literals: flags numeric literals and px/rem/em/vh/vw values inside JSX style props. Forces all sizing through theme tokens.

no-color-literals: flags hex codes, rgb(), and hsl() in any component or page file. All colors must come from CSS custom properties.

Rules apply to src/**/*.{ts,tsx} but ignore theme/, services/, and hooks/, so token definitions and low-level math can still use raw values. Only UI files are constrained.

Animation System

●Zero JS Animations

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 useSheetAnimation hook toggles a CSS exit class, then waits 240ms before calling onClose. A ref guard prevents double-fire from rapid taps. The parent never unmounts the sheet until the exit animation completes.
  • Animated numbers: use a requestAnimationFrame loop with ease-out cubic easing over 500ms. setDisplay only 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-motion collapses all durations to 0.01ms globally.

Lessons Learned

Separate platform concerns from application concerns

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.

AbortController requires discipline after every await

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.

Firebase as state layer only works with a memo chain, not effects

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.

Lint rules are stronger than code review

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

PreviousCravia: Stop Guessing. Order Right.
NextDesign System Foundation

Built with intention, not templates. If you made it this far, thank you for reading thoughtfully.

© 2026 · Designed and built with care