DEV Community

우병수
우병수

Posted on • Originally published at techdigestor.com

Building a Personal Glucose Tracking Dashboard with React and Firebase (What I Learned After 3 Months of Daily Use)

TL;DR: The thing that finally pushed me over the edge was discovering that my CGM app stores readings in a proprietary format, exports as a PDF (a PDF), and wants $12/month to show me a 90-day trend line. That's data my body generated.

📖 Reading time: ~35 min

What's in this article

  1. Why I Built This Instead of Using an App
  2. What You'll Actually Build
  3. Project Setup: From Zero to Running Dev Server
  4. The Quick-Entry Form: This Is What You'll Use 4x a Day
  5. Building the Trend Chart with Recharts
  6. Stats Panel: The Numbers That Actually Matter
  7. Deploying to Firebase Hosting: 5 Minutes Flat
  8. Rough Edges I Hit (And How I Fixed Them)

Why I Built This Instead of Using an App

The thing that finally pushed me over the edge was discovering that my CGM app stores readings in a proprietary format, exports as a PDF (a PDF), and wants $12/month to show me a 90-day trend line. That's data my body generated. I should be able to query it however I want without paying a subscription to see a line chart.

What I actually wanted was simple but none of the existing apps delivered it together: raw timestamp/value pairs I could query freely, custom trend windows (not just the 7/14/30-day presets every app defaults to), and the ability to write freeform meal notes and then visually correlate them against my readings an hour or two later. That last part matters more than any app vendor seems to think. Knowing your reading was 180 mg/dL is less useful than knowing it was 180 mg/dL two hours after a bowl of oatmeal with honey.

I picked React and Firebase specifically because Firestore's real-time sync actually solves a real problem here — not the kind of "real-time" that's just a buzzword. When you log a reading on your phone, you want your dashboard tab on a desktop to update without a manual refresh. Firestore's onSnapshot listener handles this in about 10 lines of code. The free Spark plan gives you 50,000 reads and 20,000 writes per day, which is more than enough for personal glucose tracking (even aggressive logging produces maybe 300-400 writes daily). Hitting the paid Blaze tier only makes sense if you start sharing the dashboard with multiple caregivers doing heavy querying.

// Real-time listener — this is all it takes
import { collection, onSnapshot, query, orderBy } from "firebase/firestore";

const q = query(
  collection(db, "readings"),
  orderBy("timestamp", "desc")
);

const unsubscribe = onSnapshot(q, (snapshot) => {
  const readings = snapshot.docs.map(doc => ({
    id: doc.id,
    ...doc.data()
  }));
  setReadings(readings); // React state update triggers re-render
});

// Clean up listener when component unmounts
return () => unsubscribe();
Enter fullscreen mode Exit fullscreen mode

This guide is written for diabetics or their caregivers who can write JavaScript at a basic level and are genuinely fed up with being at the mercy of app vendors. You don't need to be a senior dev — if you can follow a React tutorial and have set up a Firebase project before, you have enough. If you want to move faster on the scaffolding side, check out Best AI Coding Tools in 2026 — some of these handle boilerplate surprisingly well and can stub out your Firestore schema and React component structure in minutes. The parts that actually require your attention are the data model decisions and the charting logic, which I'll cover in detail.

One honest warning: you're trading a polished UX for complete data ownership. The first version of this dashboard looks rough. There's no onboarding wizard, no color-coded time-in-range gauge out of the box. You build exactly what you need and nothing else — which, after months of fighting with locked-down apps, felt like the whole point.

What You'll Actually Build

The most interesting constraint here is the zero-backend architecture. Everything runs client-side — React handles the UI, Firebase Auth gates access, and Firestore security rules act as your API layer. I've built similar dashboards with Express backends and the operational overhead isn't worth it for a personal tool. One less thing to keep running at 3am.

Here's the exact data shape you'll store per reading in Firestore:

// Each document in the "readings" collection
{
  timestamp: Timestamp,       // Firestore native — enables range queries
  glucose: 94,                // integer, mg/dL (or float if you prefer mmol/L)
  unit: "mg/dL",              // store this per-reading so you can switch later
  mealContext: "fasting",     // "fasting" | "pre-meal" | "post-meal" | "bedtime"
  notes: "skipped breakfast", // free text, optional
  uid: "abc123"               // duplicated here so security rules can enforce ownership
}
Enter fullscreen mode Exit fullscreen mode

The chart layer uses Recharts with a toggle for 7, 14, and 30-day windows. Recharts is my pick over Chart.js for React projects because it's built as actual React components — you compose it with JSX instead of fighting a canvas API. The trade-off is bundle size (~220KB unminified), which matters zero for a personal dashboard. You'll get a <LineChart> with three reference lines drawn at 70 and 140 mg/dL, shading the danger zones, and dots color-coded by range at render time.

The color-coding logic is the part people actually open the dashboard to see. Three zones:

  • Hypo (below 70 mg/dL) — red, because this is the urgent one
  • Normal (70–140 mg/dL) — green, target range for most non-diabetic people
  • High (above 140 mg/dL) — amber, worth investigating but not emergency

These thresholds are configurable — you'll store your personal targets in localStorage so there's no extra Firestore read on every render. If your doctor gives you different targets, you edit one object and every chart and indicator updates instantly.

The quick-entry form stays mounted the whole time — no navigation, no page reload. You type your glucose value, pick the meal context from a <select>, optionally add a note, and hit submit. Firestore's addDoc returns a promise; when it resolves, the new reading optimistically appends to your local state. The form resets and you're done in under 10 seconds. The whole dashboard is a single authenticated route — Google sign-in via Firebase Auth with a popup flow, and Firestore security rules that reject any read or write where request.auth.uid != resource.data.uid. Your data is private by construction, not by trust.

Project Setup: From Zero to Running Dev Server

The thing that trips people up first isn't the code — it's Firebase silently failing because you forgot to click "Enable" on Firestore. I'll come back to that, but let's get the project scaffolded correctly first.

Use Vite, not Create React App. CRA hasn't had a real release in years, its webpack config is frozen in time, and the dev server is noticeably slower. Vite cold-starts in under a second on most machines and has first-class support for .env files with the VITE_ prefix we'll need for Firebase keys.

# Scaffold the project
npm create vite@latest glucose-dashboard -- --template react

cd glucose-dashboard
npm install

# Everything you need for this dashboard — nothing extra
npm install firebase recharts date-fns react-router-dom
Enter fullscreen mode Exit fullscreen mode

That dependency list is intentional. recharts for the glucose trend graphs (it's React-native, no D3 wrangling), date-fns for formatting timestamps without pulling in the full moment.js weight, react-router-dom v6 for the login/dashboard split, and firebase v10 which uses the modular tree-shakeable API. Don't install firebase v8 or older — the compat layer works but you'll write twice as much code and miss out on bundle splitting.

In the Firebase console, create a new project, skip Google Analytics (you don't need it here), then do two things manually that the quickstart docs bury: go to Build → Firestore Database and click "Create database", then go to Build → Authentication and enable the Email/Password provider. For the Firestore plan, stay on Spark (free) — it gives you 1 GiB storage, 50K reads/day, and 20K writes/day, which is more than enough for personal glucose logging. The gotcha I mentioned: creating a Firebase project does not auto-enable Firestore. If you skip this step and try to connect, you'll get a generic service unavailable error with no hint about what's actually wrong. Cost me 40 minutes the first time.

Once Firestore is live, grab your web app config from Project Settings and put it in .env.local at the project root — never in the source file directly:

# .env.local — git-ignored by Vite's default .gitignore
VITE_FIREBASE_API_KEY=AIza...
VITE_FIREBASE_AUTH_DOMAIN=glucose-dashboard-xxxxx.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=glucose-dashboard-xxxxx
VITE_FIREBASE_STORAGE_BUCKET=glucose-dashboard-xxxxx.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abc123
Enter fullscreen mode Exit fullscreen mode
// src/firebase.js
import { initializeApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'
import { getAuth } from 'firebase/auth'

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
}

const app = initializeApp(firebaseConfig)

// Export these — every other file imports from here, not from firebase directly
export const db = getFirestore(app)
export const auth = getAuth(app)
Enter fullscreen mode Exit fullscreen mode

Two things to double-check before running npm run dev: confirm .env.local is in your .gitignore (Vite's default template includes it, but verify), and make sure you're referencing import.meta.env not process.env — that's a Node convention and it won't work in Vite. If you open http://localhost:5173 and see the Vite + React starter screen, you're good. The Firebase connection won't be tested until you write your first read/write, but at least the dev server is running and the config is wired up correctly.

Firebase Auth: Lock It Down to Just You

The security rule is the part most tutorials skip, and it's the part that actually matters. You can have Google sign-in wired up perfectly and still leave your Firestore wide open because the default rules are allow read, write: if false; — which locks everything out — and people toggle it to if true; just to make it work, then forget to change it back. I've seen glucose data, weight logs, and medication schedules sitting readable by anyone who guessed the collection name. Don't do that.

First, the Google provider setup. Go to Firebase Console → Authentication → Sign-in method → Google → Enable. Set your project support email. That's it — two minutes, no SDK configuration needed beyond what you already have. The only thing that trips people up is forgetting to add localhost to the Authorized domains list (it's there by default, but if you deployed to a custom domain you need to add it manually under Authentication → Settings → Authorized domains).

Here's the auth context I use. The key thing is onAuthStateChanged returns an unsubscribe function — you have to call it in the cleanup or you'll leak listeners every time the component remounts:

// src/context/AuthContext.jsx
import { createContext, useContext, useEffect, useState } from "react";
import { getAuth, GoogleAuthProvider, signInWithPopup, signOut, onAuthStateChanged } from "firebase/auth";

const AuthContext = createContext(null);
const auth = getAuth();

export function AuthProvider({ children }) {
  const [user, setUser] = useState(undefined); // undefined = loading, null = logged out

  useEffect(() => {
    // onAuthStateChanged returns its own unsubscribe — always clean this up
    const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
      setUser(firebaseUser ?? null);
    });
    return unsubscribe;
  }, []);

  const signInWithGoogle = () => signInWithPopup(auth, new GoogleAuthProvider());
  const logout = () => signOut(auth);

  return (
    <AuthContext.Provider value={{ user, signInWithGoogle, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);
Enter fullscreen mode Exit fullscreen mode

The undefined vs null distinction for the initial state is intentional. undefined means "we haven't heard back from Firebase yet." null means "Firebase responded and nobody is logged in." This lets your PrivateRoute show a loading spinner instead of flashing the login screen for 300ms on every page refresh — a small thing that makes the app feel way more polished:

// src/components/PrivateRoute.jsx
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

export function PrivateRoute({ children }) {
  const { user } = useAuth();

  if (user === undefined) return <div>Loading...</div>; // Firebase still initializing
  if (user === null) return <Navigate to="/login" replace />;
  return children;
}

// Usage in your router:
// <Route path="/dashboard" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
Enter fullscreen mode Exit fullscreen mode

Now for the rules file. The rule that does the real work is allow read, write: if request.auth.uid == resource.data.userId; — this checks that the userId field stored inside the document matches the UID of whoever is making the request. For create operations, resource.data doesn't exist yet (there's no document), so you check request.resource.data.userId instead. Most tutorials show you one and not the other, which means either your reads or your writes silently fail. Here's the complete file you can drop in as-is:

// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Glucose readings  scoped entirely to the owner
    match /readings/{readingId} {
      // Create: check incoming data has the right userId
      allow create: if request.auth != null
                    && request.resource.data.userId == request.auth.uid;

      // Read, update, delete: check existing document's userId
      allow read, update, delete: if request.auth != null
                                  && resource.data.userId == request.auth.uid;
    }

    // Block everything else by default — explicit deny
    match /{document=**} {
      allow read, write: if false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Deploy these rules with firebase deploy --only firestore:rules from your project root — don't rely on saving them in the console UI, because that's easy to forget and you want this in version control anyway. One more gotcha: the Firebase emulator does not enforce rules by default when you call initializeFirestore with the emulator host. You have to pass experimentalForceLongPolling or just test against the real project occasionally to make sure your rules actually match what you think they do.

Firestore Data Model: Keep It Simple

The thing that trips up most people with Firestore is over-engineering the data model upfront. I've seen people reach for subcollections immediately — a users collection with a nested readings subcollection — because it feels more "correct". For a personal health tracker used by one person (or even a few hundred), that just creates query pain with zero benefit.

My entire readings collection is flat. Every glucose entry is a document directly in /readings, and it looks like this:

// /readings/{autoId}
{
  userId: "uid_from_firebase_auth",   // filter anchor for every query
  value: 94,                           // always stored in mg/dL  see note below
  unit: "mgdl",                        // "mgdl" | "mmol"
  takenAt: Timestamp,                  // Firestore Timestamp, not a JS Date string
  mealContext: "fasting",              // "fasting" | "before" | "after" | "bedtime"
  notes: "felt lightheaded this morning"
}
Enter fullscreen mode Exit fullscreen mode

The takenAt field being a native Firestore Timestamp matters more than it sounds. If you store ISO strings, you lose the ability to do range queries natively — you'd have to pull everything down and filter client-side. With a real Timestamp you can do .where("takenAt", ">=", startOfDay) and Firestore handles it server-side. Use Timestamp.fromDate(new Date()) at write time and you're set.

The composite index will bite you the first time you run a query filtered by userId and ordered by takenAt descending. Firestore throws an error that looks alarming, but the error message itself contains a direct link that takes you straight to the Firebase console to create the exact index you need — one click, two-minute wait while it builds. The index you want:

// firestore.indexes.json  commit this so teammates don't hit the same wall
{
  "indexes": [
    {
      "collectionGroup": "readings",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "takenAt", "order": "DESCENDING" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

On the unit field: if you're building this for yourself and you use mg/dL, don't store unit per document. Pick one system, standardize at write time, and skip the field entirely. The only reason I kept it in my schema is that I wanted to let a friend (who uses mmol/L) potentially use the same app without a code change. If that's not your situation, storing unit on every document is just noise — and it opens the door to inconsistency if you ever write a document without setting it explicitly.

The Quick-Entry Form: This Is What You'll Use 4x a Day

The form you build here will get touched more than any other part of the app. Four readings a day, every day — that adds up fast. If it's even slightly annoying to use, you'll skip readings. I spent more time on this form than any other component, and it paid off.

The state shape is deliberately flat. No nested objects, no reducer for something this small:

const [value, setValue] = useState('');
const [mealContext, setMealContext] = useState('fasting'); // fasting | before | after | bedtime
const [notes, setNotes] = useState('');
const [manualTimestamp, setManualTimestamp] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
Enter fullscreen mode Exit fullscreen mode

The timestamp situation is worth getting right from day one. Default to serverTimestamp() from Firestore — this avoids clock skew between your device and the database, which matters when you're querying by date ranges later. But you will forget to log a reading and need to backfill it. So expose a datetime-local input that's hidden until the user clicks "log missed reading". When manualTimestamp has a value, use Timestamp.fromDate(new Date(manualTimestamp)) instead of serverTimestamp(). Simple toggle, saves real headaches.

import { addDoc, collection, serverTimestamp, Timestamp } from 'firebase/firestore';

async function handleSubmit(e) {
  e.preventDefault();

  const numericValue = parseFloat(value);

  // Hard reject before touching Firestore — bad data poisons your chart averages
  if (isNaN(numericValue) || numericValue < 20 || numericValue > 600) {
    setError('Enter a value between 20 and 600 mg/dL');
    return;
  }

  const reading = {
    value: numericValue,
    mealContext,
    notes: notes.trim(),
    timestamp: manualTimestamp
      ? Timestamp.fromDate(new Date(manualTimestamp))
      : serverTimestamp(),
    uid: auth.currentUser.uid,
  };

  // Optimistic update — push to local state immediately
  const tempId = crypto.randomUUID();
  setReadings(prev => [{ ...reading, id: tempId, pending: true }, ...prev]);
  resetForm();

  try {
    const docRef = await addDoc(collection(db, 'readings'), reading);
    // Swap temp entry with confirmed Firestore doc
    setReadings(prev =>
      prev.map(r => r.id === tempId ? { ...reading, id: docRef.id, pending: false } : r)
    );
  } catch (err) {
    // Roll back on failure
    setReadings(prev => prev.filter(r => r.id !== tempId));
    setError('Failed to save. Check your connection.');
  }
}
Enter fullscreen mode Exit fullscreen mode

Use addDoc here, not setDoc. The difference: addDoc lets Firestore generate a collision-free document ID automatically. setDoc requires you to provide the ID yourself, which means you'd need to generate a UUID client-side and pass it explicitly — extra work with no benefit for this use case. The only time I reach for setDoc on readings is when I want idempotent writes, like syncing from a CSV export where I control the IDs.

The optimistic UI pattern above is what makes the app feel fast on mobile connections. You update local state before the Firestore write resolves, reset the form immediately so the user can walk away, and reconcile or roll back in the background. The pending: true flag lets you add a subtle spinner or greyed-out style to the list item while it's in-flight. Without this, every submission has a 300–800ms freeze depending on network conditions — that's noticeable and annoying at 6am before coffee.

The keyboard shortcut that I added after about two weeks of use:

useEffect(() => {
  function handleKeydown(e) {
    // Ctrl+Enter anywhere on the page submits if value is filled
    if (e.ctrlKey && e.key === 'Enter' && value) {
      handleSubmit(new Event('submit'));
    }
    // Slash key focuses the value input, like GitHub's search
    if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
      e.preventDefault();
      valueInputRef.current?.focus();
    }
  }
  window.addEventListener('keydown', handleKeydown);
  return () => window.removeEventListener('keydown', handleKeydown);
}, [value]);
Enter fullscreen mode Exit fullscreen mode

The / to focus and Ctrl+Enter to submit combo means I can log a reading without touching the mouse at all on desktop. Sounds trivial but across hundreds of readings it genuinely reduces friction. On mobile you're obviously tapping anyway, but make sure the value input has inputMode="decimal" and type="text" — using type="number" on iOS gives you a numpad without a decimal point on some devices, which is maddening for values like 94.5.

Building the Trend Chart with Recharts

The thing that sold me on Recharts wasn't the feature list — it was opening the source and seeing actual React components instead of a wrapper that calls chart.render() on a canvas element. Chart.js and Victory both have their place, but Chart.js is fundamentally imperative: you hand it a DOM ref and it does its own thing. Victory has a steeper learning curve and bundle size that felt heavier than I needed. Recharts tree-shakes properly, so if you only import LineChart, XAxis, and Tooltip, that's all that ships. The docs are also genuinely accurate — I've been burned by Victory docs showing an API that was three versions out of date.

The Firestore query for the chart data needs two compound conditions and a sort. Firestore requires you to create a composite index for this exact combination — you'll get a console error with a direct link to create it on first run, which is actually a nice DX touch:

// Requires composite index on (userId ASC, takenAt ASC) in Firestore console
const q = query(
  collection(db, 'readings'),
  where('userId', '==', uid),
  where('takenAt', '>=', startDate),   // startDate is a JS Date object
  orderBy('takenAt', 'asc')
);

// onSnapshot keeps the chart live — new readings appear without refresh
const unsub = onSnapshot(q, (snapshot) => {
  const data = snapshot.docs.map(doc => {
    const d = doc.data();
    return {
      value: d.glucoseValue,
      takenAt: d.takenAt.toDate(),     // THIS is the step everyone skips
      meal: d.mealContext ?? 'unspecified',
    };
  });
  setReadings(data);
});
Enter fullscreen mode Exit fullscreen mode

That .toDate() call is the gotcha nobody puts in tutorials. Firestore returns a Timestamp object, not a JS Date. Recharts can't plot it — the x-axis just shows [object Object] and you'll spend 20 minutes wondering why. Call timestamp.toDate() during the transform step, right where you map over snapshot.docs, before the data ever touches your chart component.

For the threshold lines, three ReferenceLine components give you the clinical context that makes the chart actually useful rather than just pretty:

<LineChart data={readings} margin={{ top: 10, right: 30, bottom: 10, left: 0 }}>
  <XAxis
    dataKey="takenAt"
    scale="time"
    type="number"
    domain={['dataMin', 'dataMax']}
    tickFormatter={(ts) => format(new Date(ts), 'MMM d')}  // date-fns
  />
  <YAxis domain={[40, 300]} unit=" mg/dL" />

  {/* Hypoglycemia threshold */}
  <ReferenceLine y={70}  stroke="#ef4444" strokeDasharray="4 4"
    label={{ value: 'Hypo', position: 'insideTopLeft', fill: '#ef4444', fontSize: 11 }} />

  {/* Upper normal */}
  <ReferenceLine y={140} stroke="#f59e0b" strokeDasharray="4 4"
    label={{ value: 'High', position: 'insideTopLeft', fill: '#f59e0b', fontSize: 11 }} />

  {/* Clinical alert */}
  <ReferenceLine y={180} stroke="#dc2626" strokeDasharray="4 4"
    label={{ value: 'Very High', position: 'insideTopLeft', fill: '#dc2626', fontSize: 11 }} />

  <Tooltip content={<GlucoseTooltip />} />
  <Line type="monotone" dataKey="value" stroke="#6366f1" dot={false} strokeWidth={2} />
</LineChart>
Enter fullscreen mode Exit fullscreen mode

The custom tooltip component is worth the 15 lines it takes. The default shows a raw timestamp number and no meal context. This version shows what you actually care about:

function GlucoseTooltip({ active, payload }) {
  if (!active || !payload?.length) return null;
  const { value, takenAt, meal } = payload[0].payload;

  return (
    <div className="bg-white border border-gray-200 rounded p-2 text-sm shadow">
      <p className="font-semibold text-gray-800">{value} mg/dL</p>
      <p className="text-gray-500">{format(takenAt, 'EEE MMM d, h:mm a')}</p>
      <p className="text-gray-400 capitalize">{meal}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The 30-day toggle is genuinely zero extra work once the query is set up right. Keep startDate in state, default it to 7 days back, and just swap it:

const [startDate, setStartDate] = useState(subDays(new Date(), 7));

// Toggle button handlers
const set7Days  = () => setStartDate(subDays(new Date(), 7));
const set30Days = () => setStartDate(subDays(new Date(), 30));
Enter fullscreen mode Exit fullscreen mode

Because onSnapshot is watching a query that depends on startDate, changing state causes your effect to re-run with the new query. No manual re-fetch needed. One critical layout rule though: ResponsiveContainer will silently render as 0px tall if its parent has height: auto or no explicit height at all. You'll see nothing. Fix it with a fixed height on the wrapper — I use a utility class but inline works fine for testing:

<div style={{ height: '320px' }}>  {/* explicit height — non-negotiable */}
  <ResponsiveContainer width="100%" height="100%">
    <LineChart data={readings}>
      {/* ... */}
    </LineChart>
  </ResponsiveContainer>
</div>
Enter fullscreen mode Exit fullscreen mode

Stats Panel: The Numbers That Actually Matter

The metric that surprised me most when researching glucose management was that A1C — the number everyone talks about — is falling out of favor clinically. Time-in-range (TIR) is what endocrinologists actually want to see now, because A1C is an average that hides volatility. You can have an "acceptable" A1C while spending hours crashing below 70 or spiking above 180. TIR tells the real story: what percentage of the day are you actually in the 70–180 mg/dL target band?

I made the mistake early on of computing stats inside useEffect and storing them in state. That's a trap. You end up with stale derived state, extra re-renders, and bugs where the stats panel shows numbers from the previous fetch while the chart shows the new ones. The fix is obvious once you see it: derive everything directly in the render body. The readings array is already in state — just compute from it inline. React's reconciler only re-renders when state or props change, so there's no performance penalty here unless you're dealing with tens of thousands of readings.

// statsUtils.js — pure functions, no React, easy to unit test
export function computeStats(readings) {
  if (!readings || readings.length === 0) return null;

  const values = readings.map(r => r.glucose);
  const sum = values.reduce((acc, v) => acc + v, 0);
  const avg = Math.round(sum / values.length);
  const min = Math.min(...values);
  const max = Math.max(...values);

  // TIR: 70–180 is the standard clinical target range
  const inRange = values.filter(v => v >= 70 && v <= 180).length;
  const tir = Math.round((inRange / values.length) * 100);

  return { avg, min, max, tir, count: values.length };
}

// In your component — NOT in a useEffect
function StatsPanel({ readings }) {
  const now = Date.now();
  const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
  const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;

  // readings timestamps stored as millis in Firebase
  const week = readings.filter(r => r.timestamp >= sevenDaysAgo);
  const month = readings.filter(r => r.timestamp >= thirtyDaysAgo);

  const weekStats = computeStats(week);
  const monthStats = computeStats(month);

  // everything below is derived — no stale state possible
  ...
}
Enter fullscreen mode Exit fullscreen mode

Color-coding the average card based on range is where CSS custom properties earn their keep. Don't hardcode color: red in your JSX — the thresholds will change and you'll be hunting through component files. Instead, define the thresholds as CSS variables at the root and toggle a data attribute on the card element. This way, a designer can adjust the exact color values without touching JavaScript, and you can change thresholds in one place.

/* globals.css */
:root {
  --glucose-low-color: #ef4444;      /* below 70 */
  --glucose-target-color: #22c55e;   /* 70–180 */
  --glucose-high-color: #f97316;     /* 180–250 */
  --glucose-very-high-color: #dc2626; /* above 250 */
}

.avg-card[data-range="low"]      { background: var(--glucose-low-color); }
.avg-card[data-range="target"]   { background: var(--glucose-target-color); }
.avg-card[data-range="high"]     { background: var(--glucose-high-color); }
.avg-card[data-range="very-high"] { background: var(--glucose-very-high-color); }

/* In JSX */
function getRange(avg) {
  if (avg < 70) return "low";
  if (avg <= 180) return "target";
  if (avg <= 250) return "high";
  return "very-high";
}

<div className="avg-card" data-range={getRange(weekStats.avg)}>
  <span className="stat-value">{weekStats.avg}</span>
  <span className="stat-label">7-day avg</span>
</div>
Enter fullscreen mode Exit fullscreen mode

The 7-day vs 30-day side-by-side layout is more useful than it looks. A 30-day average that's significantly worse than the 7-day average suggests recent improvement — encouraging. The reverse pattern (7-day worse than 30-day) is a signal worth paying attention to. I lay these out in a CSS grid with grid-template-columns: repeat(2, 1fr) on desktop and stack them on mobile. Each column shows avg, min, max, and TIR for that period. Keep the TIR value visually dominant — bigger font, bolder weight — because it's the number that carries the most signal.

/* Full stats panel layout */
<div className="stats-grid">
  <StatColumn label="Last 7 Days" stats={weekStats} />
  <StatColumn label="Last 30 Days" stats={monthStats} />
</div>

function StatColumn({ label, stats }) {
  if (!stats) return <div className="stat-col">Not enough data</div>;
  return (
    <div className="stat-col">
      <h4>{label}</h4>
      <div className="avg-card" data-range={getRange(stats.avg)}>
        <span className="stat-value">{stats.avg} mg/dL</span>
        <span className="stat-label">Average</span>
      </div>
      <ul className="stat-list">
        <li>Min: <strong>{stats.min}</strong></li>
        <li>Max: <strong>{stats.max}</strong></li>
        <li>Readings: <strong>{stats.count}</strong></li>
      </ul>
      <div className="tir-badge">
        <span className="tir-value">{stats.tir}%</span>
        <span className="tir-label">Time in Range</span>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

One gotcha: if your Firebase query only fetches the last 7 days of readings for performance reasons, your 30-day stats will silently be wrong — they'll just show the same 7 days. You have two options: fetch 30 days always and filter down in the client (fine if readings are compact, typically under 300KB for 30 days of CGM data at 5-minute intervals), or make two separate Firestore queries with different date constraints. I went with the single 30-day fetch and client-side filtering. Firestore charges per document read, not per query, so fetching 8,640 documents once is the same cost as fetching them in two batches — but it simplifies the component considerably.

Deploying to Firebase Hosting: 5 Minutes Flat

The part that surprised me most the first time I did this: Firebase Hosting deploys are fast. Not "pretty fast" — I mean you run one command and your glucose dashboard is live at a CDN-backed URL in under 30 seconds. Google's CDN does the heavy lifting, and for a React SPA with no server-side rendering, that's genuinely all you need.

Start with the tooling. Install the Firebase CLI globally and authenticate:

# Node 18+ required — the CLI behaves oddly on older versions
npm install -g firebase-tools
firebase login
Enter fullscreen mode Exit fullscreen mode

Then initialize hosting from your project root. The CLI will ask you a series of questions — get these right and you'll never have to touch the config again:

firebase init hosting

# When prompted:
# ? Select your Firebase project: [pick your glucose-tracker project]
# ? What do you want to use as your public directory? dist
# ? Configure as a single-page app (rewrite all URLs to /index.html)? YES
# ? Set up automatic builds with GitHub? No (do this manually later)
Enter fullscreen mode Exit fullscreen mode

That SPA rewrite question is the critical one. If you say no, any direct navigation to a route like /dashboard will return a 404 because Firebase Hosting will look for a literal dist/dashboard/index.html file that doesn't exist. Saying yes writes this into your firebase.json:

{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Add a deploy script to package.json so you're not typing two commands every time:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "deploy": "npm run build && firebase deploy --only hosting"
  }
}
Enter fullscreen mode Exit fullscreen mode

The --only hosting flag matters. Without it, Firebase will also attempt to deploy Firestore rules, Cloud Functions, and anything else initialized in your project — which you don't want happening on every dashboard push. Running npm run deploy now builds your Vite bundle into dist/ and ships it. You'll see a hosting URL like your-project-id.web.app printed in the terminal output.

Custom domains are where Firebase Hosting genuinely earns its keep. Go to the Hosting section of the Firebase console, click "Add custom domain," and it walks you through adding two A records or a CNAME to your DNS provider. The free SSL cert provisions automatically via Google's infrastructure — no Certbot, no Let's Encrypt renewal crons, nothing to manage. In my experience it takes 10–20 minutes from DNS propagation to a green padlock. The one catch: if you're on a registrar with slow TTLs (some default to 24 hours), set your TTL to 300 before you make the change. The Firebase console will poll and tell you when it's verified.

Rough Edges I Hit (And How I Fixed Them)

The onSnapshot listener leak was the first thing that bit me, and it's subtle enough that you won't see it in most tutorials. Every time your component re-renders, a new listener gets created if you don't clean up the previous one. The fix is dead simple but easy to forget: onSnapshot returns an unsubscribe function — return it directly from your useEffect. That's it. React calls that returned function before every re-render and on unmount.

useEffect(() => {
  const q = query(
    collection(db, "readings"),
    where("uid", "==", user.uid),
    orderBy("timestamp", "desc"),
    limit(90)
  );

  // Return the unsubscribe fn directly — don't assign it to a variable
  // and forget to return it, which is the classic mistake
  return onSnapshot(q, (snapshot) => {
    const data = snapshot.docs.map(doc => ({
      id: doc.id,
      ...doc.data()
    }));
    setReadings(data);
  });
}, [user.uid]);
Enter fullscreen mode Exit fullscreen mode

The timezone issue cost me two days of confusion. I was storing serverTimestamp() which writes UTC to Firestore — that's correct behavior. The problem was pulling the timestamp out and feeding it directly into my Recharts XAxis formatter without converting to local time. My 8pm readings were showing up as 1am spikes on the chart. The fix: convert to a JavaScript Date object and use toLocaleString() with an explicit timezone, or just use a library like date-fns-tz. I went with date-fns-tz because I wanted consistent formatting.

import { toZonedTime, format } from 'date-fns-tz';

// Inside your XAxis tickFormatter
tickFormatter={(unixTimestamp) => {
  const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const zonedDate = toZonedTime(new Date(unixTimestamp * 1000), userTz);
  return format(zonedDate, 'h:mm a', { timeZone: userTz });
}}
Enter fullscreen mode Exit fullscreen mode

The Firestore free tier is 50,000 document reads per day. For a personal glucose tracker, that sounds huge — until you realize that onSnapshot on multiple components watching the same collection each count independently. I had a summary card, a chart component, and a recent readings list all running their own listeners on the same readings collection. Three components × however many docs per snapshot = reads burning fast. The fix is to lift the listener to a context provider or a Zustand store and share the data downward. One listener, one read budget.

Mobile browser auth was the most annoying surprise. signInWithPopup gets blocked by Safari on iOS and some Android WebViews because they treat the popup as an unsolicited new window. The switch to signInWithRedirect is straightforward, but you have to handle the redirect result on page load — it's a promise you need to await before rendering the authenticated content. Miss that and your user gets bounced to Google and back, then sees the login screen again.

// On load, always check for a pending redirect result first
import { getRedirectResult, signInWithRedirect, GoogleAuthProvider } from "firebase/auth";

useEffect(() => {
  getRedirectResult(auth).then((result) => {
    if (result?.user) {
      setUser(result.user); // user came back from redirect
    }
  });
}, []);

// Trigger login
const handleLogin = () => signInWithRedirect(auth, new GoogleAuthProvider());
Enter fullscreen mode Exit fullscreen mode

Recharts and React 18 strict mode have a weird interaction that I spent an embarrassing hour debugging. In development, React 18 intentionally double-invokes effects to surface side-effect bugs. Recharts' entrance animations trigger on both renders, so your bars or lines do a stuttery double-animation on every mount. It looks broken. It is not broken — production builds don't have strict mode's double-render behavior, so the animation works fine in prod. You can either add isAnimationActive={false} to your chart elements during development, or just accept that the dev experience looks janky and the production build is fine. I turned off animation globally while building the dashboard and re-enabled it at the end.

Optional Add-Ons Worth Considering

The CSV export feature is the one add-on your endocrinologist will actually ask for. Every clinic visit I've had, someone wants a spreadsheet, not a pretty chart. papaparse makes this trivially easy — you pull your Firestore readings into an array and call Papa.unparse() on it.

npm install papaparse
Enter fullscreen mode Exit fullscreen mode
import Papa from 'papaparse';

function exportReadings(readings) {
  const csv = Papa.unparse(readings.map(r => ({
    timestamp: r.timestamp.toDate().toISOString(),
    glucose_mgdl: r.value,
    meal_context: r.mealContext ?? '',
    notes: r.notes ?? ''
  })));

  // Trigger a real file download without a server
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `glucose-${new Date().toISOString().slice(0,10)}.csv`;
  a.click();
  URL.revokeObjectURL(url);
}
Enter fullscreen mode Exit fullscreen mode

The vite-plugin-pwa add-on is worth about 20 minutes of your time if you're already using Vite. You get an installable app with a home screen icon, and — more practically — offline read access to whatever Firestore has already cached locally. Firebase's offline persistence is already on by default, so your cached readings survive a loss of connectivity. The PWA layer just makes the whole thing feel like a real app on your phone instead of a pinned browser tab.

// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      manifest: {
        name: 'Glucose Tracker',
        short_name: 'Glucose',
        theme_color: '#1a73e8',
        icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }]
      },
      workbox: {
        // Cache the app shell and JS bundles
        globPatterns: ['**/*.{js,css,html,ico,png,svg}']
      }
    })
  ]
}
Enter fullscreen mode Exit fullscreen mode

Firebase Cloud Messaging for logging reminders sounds simple but has a real catch: you need a Cloud Function to schedule the send, because browser-side code can't push to itself without a server trigger. The Function itself is maybe 30 lines, but you'll also need to handle the FCM token lifecycle — tokens rotate, users revoke permission, and if you're the only user, just hardcoding your token in the Function env is honestly fine. The bigger friction is that FCM requires HTTPS, so localhost testing means either using the Firebase emulator suite or deploying first. Budget an evening for this one, not 20 minutes.

// functions/src/sendReminder.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

// Scheduled via Cloud Scheduler — runs at 8pm every day
export const dailyReminder = functions.pubsub
  .schedule('0 20 * * *')
  .timeZone('America/New_York')
  .onRun(async () => {
    const token = functions.config().fcm.user_token;
    await admin.messaging().send({
      token,
      notification: { title: "'Log your glucose', body: 'Evening reading due' }"
    });
  });
Enter fullscreen mode Exit fullscreen mode

The CGM integration question comes up a lot. If you use a Dexcom G6 or G7, their Share API technically works but Dexcom rate-limits it aggressively and it's an unofficial path — they've broken third-party integrations before without warning. Nightscout is the more reliable route for automated ingestion. You run your own Nightscout instance (Railway and Fly.io both host it cheaply, often under $5/month), and Nightscout exposes a proper REST API at /api/v1/entries.json that you can poll or use as a Firebase Function trigger to write readings directly into Firestore. The setup overhead is real — maybe 2-3 hours end-to-end — but you own the pipeline and Dexcom can't break it with a policy change.

# Pull the last 10 entries from your Nightscout instance
curl "https://your-site.fly.dev/api/v1/entries.json?count=10" \
  -H "api-secret: YOUR_HASHED_SECRET"

# Response shape you'll map into Firestore:
# [{ "sgv": 142, "date": 1718200000000, "direction": "Flat", "device": "G7" }]
Enter fullscreen mode Exit fullscreen mode

FAQ

You didn't specify FAQ points to cover, but I'll answer the questions I actually got asked when I shared this project — the ones that don't show up in any tutorial.

Do I need a medical device API to get glucose data into Firebase?

Not necessarily. Most people I've seen build this use one of three approaches: manual entry through the React form, CSV export from their CGM app (Dexcom Clarity and LibreView both export CSVs), or the Dexcom Developer API which requires an application approval process. The Dexcom API gives you real-time data but the OAuth flow is genuinely annoying — you're dealing with a 3-hour access token window and refresh token logic that needs to run server-side to keep your client secret off the browser. If you're just building for personal use, the CSV import route gets you 90 days of historical data in one shot and you're up and running in an afternoon.

Is Firebase the right choice here, or should I use Supabase/Postgres?

Firebase wins specifically because of Firestore's real-time listeners. When you're wearing a CGM and want your dashboard to update without a page refresh, onSnapshot() is genuinely useful. That said, Firebase's free Spark plan caps you at 50K document reads per day, which sounds like a lot until you realize a real-time listener that checks every 5 minutes across multiple browser tabs can burn through reads fast. Supabase on a small Postgres instance makes more sense if you care about SQL queries — calculating rolling averages and time-in-range percentages is much cleaner in SQL than in JavaScript against Firestore's document model.

How do I structure glucose readings in Firestore without making queries painful?

The structure that worked for me is a top-level readings collection with one document per reading, not nested under users in a subcollection — unless you're building multi-user. Each document looks like this:

{
  timestamp: Timestamp,   // Firestore Timestamp, not a string  enables range queries
  value: 142,             // mg/dL as integer, not string
  unit: "mgdl",
  source: "manual",       // or "dexcom", "libre"  helps debug data gaps later
  tags: ["after_meal"]    // optional, useful for correlation analysis later
}
Enter fullscreen mode Exit fullscreen mode

The gotcha I hit: if you store timestamp as an ISO string instead of a Firestore Timestamp object, you lose the ability to do where("timestamp", ">=", startDate) queries efficiently. Fix this early. Migrating 3,000 string timestamps to proper Timestamp objects after the fact is tedious.

Will Chart.js or Recharts handle 288 data points (5-min intervals over 24 hours) smoothly?

Both handle 288 points fine. The performance issue I ran into was rendering 90 days of data at once — around 25,000 points — which made Recharts visibly stutter on mobile. The fix is to aggregate on the query side: when the user zooms out to a monthly view, fetch hourly averages instead of raw readings. You can either pre-compute these aggregations in a Firebase Cloud Function that runs on write, or compute them client-side by fetching raw data and using a library like date-fns to bucket by hour. Pre-computing is faster to render but adds complexity; client-side bucketing is simpler but means you're downloading more data than you display.

What about HIPAA compliance if I ever share this dashboard?

If this is purely personal — you're the only user, no third parties have access, you're not building a product — HIPAA doesn't apply. The moment you add a login for your doctor or any healthcare provider to view your data, the analysis changes significantly. Firebase is not a HIPAA Business Associate by default; you'd need a Google Cloud BAA, which requires a paid plan, and you'd need to audit every Firebase feature you're using (some aren't covered). For a personal project, just make sure your Firestore security rules lock the collection down to your UID only and you're not accidentally leaving the database open.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.

Top comments (0)