Building HabiTrack: AI-Powered Health & Habits Tracking with FastAPI
How I built a multi-user health tracker with Groq vision-based metric extraction, a 15-achievement system, calendar heatmaps, and an invite-only admin model — and what I learned switching from Flask to FastAPI.
After building FinTrack I had a pattern I trusted — Supabase backend, Railway hosting, Groq for AI. What I didn't have was a way to track whether I was actually healthy. Spreadsheets for weight and habits are even worse than spreadsheets for finances.
HabiTrack is my answer — a self-hosted health and habits tracker that does more than just log numbers. It extracts metrics from smart scale photos, tracks streaks, fires achievement notifications, and generates AI health analysis on demand. It's live at habitrack.gautam-pai.com.
→ Try HabiTrack LiveWhy FastAPI Instead of Flask
FinTrack is built on Flask. HabiTrack uses FastAPI. The switch was deliberate — I wanted to see how they differed in practice for a similar type of app.
What FastAPI does better: automatic request validation via Pydantic, async support out of the box, and auto-generated API docs at /docs. For an app with complex nested request bodies (like the health metric submission with 27 optional fields), Pydantic models eliminate an entire category of validation bugs.
What Flask does better: simplicity for server-rendered HTML apps. FastAPI is designed for JSON APIs first — using it with Jinja2 templates (as I did) works, but it's slightly against the grain. For purely template-driven apps with minimal JS, Flask is more natural. For anything with a real API layer, FastAPI wins.
The router structure ended up cleaner too — each feature area has its own router file with a clear prefix, making the codebase much easier to navigate than a monolithic Flask app.py.
The Vision-Based Metric Extraction
This is the feature I'm most proud of. Smart scales like Fitdays output a detailed body composition report — weight, BMI, body fat, muscle mass, visceral fat, BMR, and 20+ other metrics. Manually typing all of that is painful. So I built image upload with AI extraction.
The flow: user photographs their scale display or report → uploads the image → Groq's vision model (LLaMA 3 Vision) extracts all 27 metrics → user confirms the extracted data → saved to the database.
async def extract_health_metrics_from_image(image_bytes: bytes, mime_type: str):
# Convert image to base64 for Groq vision API
b64 = base64.b64encode(image_bytes).decode()
prompt = """Extract ALL visible health metrics from this scale/report image.
Return ONLY valid JSON with these exact keys (null if not visible):
weight_kg, bmi, body_fat_pct, fat_mass_kg, muscle_mass_kg,
visceral_fat_index, bmr_kcal, body_age, bone_mass_kg,
protein_mass_kg, water_weight_kg ...
CRITICAL: Numbers only. No units in values. null if not visible."""
response = groq_client.chat.completions.create(
model="llama-3.2-11b-vision-preview",
messages=[{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{b64}"}
}, {"type": "text", "text": prompt}]
}],
temperature=0,
max_tokens=500
)
The confirmation UI shows a side-by-side: extracted values on the left, editable form fields on the right. User can correct any field before saving. This human-in-the-loop step is what makes the feature trustworthy rather than just cool.
The Achievement System: 15 Milestones Across 5 Categories
Achievements are one of those features that sound simple to build and aren't. The challenge is checking achievements efficiently without running 15 queries on every habit toggle.
My solution: a single check_and_award_achievements(user_id) function that batches all the data it needs in one pass, then evaluates all 15 achievement conditions in memory:
async def check_and_award_achievements(user_id: int, db: Session):
# Fetch everything needed in one pass
logs = db.query(HabitLog).filter_by(user_id=user_id).all()
habits = db.query(Habit).filter_by(user_id=user_id).all()
health = db.query(HealthMetric).filter_by(user_id=user_id).all()
earned = {a.achievement_id for a in
db.query(Achievement).filter_by(user_id=user_id).all()}
newly_earned = []
for ach_id, condition in ACHIEVEMENT_DEFINITIONS.items():
if ach_id not in earned and condition(logs, habits, health):
# Award it
db.add(Achievement(user_id=user_id, achievement_id=ach_id))
newly_earned.append(ach_id)
db.commit()
return newly_earned # returned to frontend for toast notifications
The achievement definitions are pure functions — condition(logs, habits, health) → bool. This makes them easy to test, easy to add, and keeps all the business logic in one place. New achievements are just new entries in ACHIEVEMENT_DEFINITIONS.
Newly earned achievements come back in the API response and the frontend fires toast notifications immediately — no polling, no separate endpoint.
The Calendar Heatmap
The Habit History page shows a full monthly calendar where each day's colour intensity reflects the completion percentage for that day. Days with 100% completion get a gold star highlight.
The interesting part is building this purely in vanilla JS without a charting library. Each day cell is a div — colour computed from the completion ratio using HSL interpolation from green (100%) through yellow to red (0%), with grey for days with no data:
function completionToColor(pct) {
if (pct === null) return '#1c211f'; // no data
if (pct === 100) return '#f0c060'; // gold — perfect day
// HSL: 120 = green, 60 = yellow, 0 = red
const hue = Math.round(pct * 1.2);
return `hsl(${hue}, 70%, 35%)`;
}
Tooltip on hover shows the exact completed/total count and percentage for that day. Navigation arrows let you browse backwards through any past month. The per-habit breakdown table below the calendar shows each habit's done count and monthly percentage for the selected month.
Invite-Only Auth with Admin Controls
HabiTrack uses the same invite-token model as FinTrack but with a more complete admin surface. Admins can generate single-use invite links, view all registered users, and — importantly — disable accounts with a custom reason note.
The disable mechanism is middleware-level, not just a login check:
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
user = await get_current_user(request)
if user and user.is_disabled:
# Block immediately — redirect to login with reason shown
return RedirectResponse("/login?disabled=1")
return await call_next(request)
This means a disabled user is blocked on their next request, not just their next login. The reason note the admin entered is shown on the login page so the user knows why. Re-enabling is one click in the admin panel — the change takes effect immediately.
SQLAlchemy + Supabase: A Note
HabiTrack uses SQLAlchemy ORM on top of Supabase's PostgreSQL connection string — different from FinTrack which uses the Supabase Python client directly. The ORM approach gives you proper model definitions, relationship traversal, and migrations, but adds setup complexity.
The first-run migration pattern I settled on: init_db() runs all ALTER TABLE column migrations before any ORM query, then seeds the admin user and default habits if they don't exist. No manual SQL migration files needed — the migration logic lives in code and runs idempotently on every startup.
def init_db():
# Run all column additions safely
for stmt in MIGRATIONS:
try:
db.execute(text(stmt))
db.commit()
except Exception:
db.rollback() # Column already exists — fine
# Seed admin user if not exists
seed_admin(db)
seed_default_habits(db)
The tradeoff: startup is slightly slower (running migrations on every boot), but you never have a mismatch between schema and code in production.
What I'd Do Differently
Use FastAPI's async properly. Most of my DB calls are still synchronous SQLAlchemy — I'm running them in a thread pool executor. Switching to an async ORM (like SQLModel or encode/databases) would make the async story consistent throughout.
Push notifications for streak reminders. The app knows when you haven't logged habits today and when a streak is about to break. A daily reminder push or email at a user-configured time would significantly improve the habit-building value of the app.
The image upload confirmation UX needs work on mobile. Photographing a scale and confirming extracted metrics on a phone screen is clunky — the confirmation form is too long for a small viewport. A step-by-step wizard would be much better.
Running It Yourself
HabiTrack is self-hosted with Railway. You need a Supabase PostgreSQL project, a Groq API key (free tier is generous for personal use), and a Railway account. Every push to main auto-deploys via GitHub integration.
Or try the live version at habitrack.gautam-pai.com. Request an invite link and I'll send one over.