Case study · 2025 – 2026 · Personal project
AppFullSport: a full-stack fitness app with a local LLM coach, React Native, FastAPI and PostgreSQL
A mobile fitness app with workout tracking, nutrition, a social feed and an AI coach — where the language model runs locally on a CPU-only server, and where the hardest bug lived in Kotlin code I never wrote.

- Role
- Sole developer — mobile app, API, admin dashboard, AI, deployment
- Stack
- Expo 54 · React Native 0.81 · FastAPI · SQLAlchemy 2 + Alembic · PostgreSQL 16 · Ollama · nginx
- Scale
- 104 API routes · 23 database tables · 29 screens · 83 seeded exercises · EN / FR / AR
- Status
- Backend running on a self-managed VPS; app used through Expo Go / EAS builds — not on the stores yet
What it does
- Workouts. Templates built from an exercise catalog (83 exercises seeded, most with images from a public-domain database), live sessions with per-set weight and reps, a rest timer, and automatic personal-record detection — the previous best is computed before the new set is inserted, so a PR is detected at the moment it happens.
- Nutrition. Meals, foods, goals, water intake, and AI food-photo analysis: photograph a plate, get an estimate.
- An AI coach ("The Bull") with five modes: a voice/text command classifier, a personalised plan, a daily roadmap, a weekly check-in and a free-text workout generator.
- Progress. Weight entries, body measurements and progress photos.
- Social. Friends, posts with likes and comments, challenges, and an explore tab.
- Steps and runs. The device pedometer with a 7-day backfill, Android Health Connect, and a run screen with a map.
- A web admin dashboard (8 pages) for users, content moderation and AI usage analytics.
Architecture
Four Docker Compose services, all with healthchecks. Only nginx is reachable from outside; the API, the database and the LLM runner are reachable on the internal network only. The entrypoint waits for the database, runs alembic upgrade head, and seeds the exercise catalog idempotently on every boot.
Running an LLM coach without a GPU
The text model is qwen2.5 7B-instruct in Ollama, on CPU. That is slow, and the design accepts it rather than hiding it:
- One parallel slot. Two simultaneous generations on a CPU both finish late; one at a time gives better tail latency. RAM is capped at 7 GB against a working set of about 6.
- The model is pinned in memory (
OLLAMA_KEEP_ALIVE=-1) and a non-blocking warm-up task at boot primes the same JSON-mode path the app uses, so the first real user does not pay the cold start. - Timeouts are explicit at every layer: 180 s for Ollama, 30 s for hosted providers, 120 s in nginx for
/api/ai/. - The provider is a flag. The same code path can target a hosted API, so moving to a GPU later is a configuration change, not a rewrite.
- Small models lie about JSON. A system-only prompt in JSON mode returned
{}— so a user turn is synthesised. And the response parser is tolerant: it strips Markdown fences, scans brackets with string awareness, and repairs trailing commas before giving up.
Food photos go the other way: they are always proxied through the backend to a hosted vision model, so the API key never ships inside the app.
The bug that lived in someone else's Kotlin
On a Samsung A10, opening the steps screen killed the app with an UninitializedPropertyAccessException — inside the Health Connect library, below anything JavaScript can catch. In order, I tried: a safe-mode diagnostic build, gating every call on the SDK status, and removing the library entirely.
The actual cause: the library expects its permission delegate to be registered in the activity's onCreate, and in an Expo app you do not own that file. The fix is a custom Expo config plugin that patches MainActivity.kt at prebuild time to call HealthConnectPermissionDelegate.setPermissionDelegate(this).
Then a second, quieter failure: requestPermission() returned an empty list and no error. The library's own plugin does not declare the android.permission.health.* entries in the manifest; eight of them had to be added by hand. Silent empty results are worse than crashes.
Smaller decisions I would make again
- Localised API errors. The backend translates its error messages from the
Accept-Languageheader, so a French user never sees an English validation error from the server. - An avatar pipeline that distrusts uploads: 5 MB cap, Pillow
verify(), EXIF transpose, downscale to 512 × 512, re-encode as WebP. - The model volume is disposable. An auto-pull entrypoint means wiping the Ollama volume recovers by itself.
- Arabic is a first-class layout, not a translation: 343 keys per locale with parity, and
I18nManager.forceRTL, which only takes effect after an app restart.
Security
A strict password policy (12+ characters, four classes, a blocklist, and it may not contain your name or email), account lockout after 5 failures, bcrypt, short reset codes from secrets with a 15-minute expiry, per-route rate limits in the API (register 5/min, login 10/min, forgot-password 3/min) and limit_req zones in nginx (2 r/s on the AI routes), a restricted CORS list, CSP and the usual security headers, a non-root backend container, and a global exception handler that never leaks internals.
What is not done
I would rather list it than have you find it: there is no real email delivery yet (reset codes are logged server-side), the subscription screen is a placeholder, there are no push notifications, some screens are not translated, and there is no automated test suite — this project predates the habit I built on the ones that followed it. TLS is configured and ready but waits for a domain name.