GlucoseIQ

Build a Live Glucose View

Combine current readings, derived trend, and sensor staleness without tying your interface to one CGM vendor.

A live glucose view has two jobs:

  1. Your data layer receives new CGM readings through polling, a subscription, or a server-side connector.
  2. GlucoseIQ turns recent normalized readings into a current-reading view model.

Keeping those jobs separate lets the same interface work with Dexcom, Libre, Nightscout, a simulator, or a future source.

Descriptive, not predictive

The live model describes readings already received. It does not forecast glucose, trigger alarms, replace the source device, or provide medical advice.

The live view model

The live view model uses four functions from @glucoseiq/core:

FunctionUse it for
latestReadingFind the newest fully usable reading without pre-sorting input
computeGlucoseTrendFit a rate of change over a trailing window and classify the trend
minutesSinceLastReadingMeasure time since the latest reading using the current time or a supplied time
classifyGlucoseTrendClassify an existing mg/dL-per-minute rate

For React, useGlucoseLive combines the first three into one view model.

1. Keep a rolling history

A trend needs at least two valid readings with different timestamps inside its trailing window. Keep a small immutable history instead of replacing the array with only the newest reading. Put the merge helper in merge-readings.ts:

import type { GlucoseReading } from '@glucoseiq/core'

export function mergeReadings(
  current: GlucoseReading[],
  incoming: GlucoseReading[],
  limit = 48,
): GlucoseReading[] {
  const byTimestamp = new Map(
    current.map((reading) => [reading.timestamp, reading]),
  )

  for (const reading of incoming) {
    byTimestamp.set(reading.timestamp, reading)
  }

  return [...byTimestamp.values()]
    .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
    .slice(-limit)
}

This helper uses the exact timestamp string as the reading's ID. An incoming reading replaces an earlier reading with the same timestamp string. Different strings that describe the same time stay separate.

With one reading every five minutes, 48 readings cover about four hours. The default trend fit uses only the trailing 15 minutes.

2. Receive readings from your data layer

Fetch readings in your data layer. This polling hook expects normalized readings from the endpoint and keeps a rolling window in the client:

'use client'

import { useEffect, useState } from 'react'
import type { GlucoseReading } from '@glucoseiq/core'
import { mergeReadings } from './merge-readings'

export function useReadingFeed() {
  const [readings, setReadings] = useState<GlucoseReading[]>([])
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const controller = new AbortController()

    async function refresh() {
      try {
        const response = await fetch('/api/glucose/current', {
          cache: 'no-store',
          signal: controller.signal,
        })

        if (!response.ok) {
          throw new Error(`Glucose feed returned ${response.status}`)
        }

        const incoming = (await response.json()) as GlucoseReading[]
        setReadings((current) => mergeReadings(current, incoming))
        setError(null)
      } catch (cause) {
        if (!controller.signal.aborted) {
          setError(cause instanceof Error ? cause : new Error('Feed failed'))
        }
      }
    }

    void refresh()
    const interval = window.setInterval(() => void refresh(), 60_000)

    return () => {
      controller.abort()
      window.clearInterval(interval)
    }
  }, [])

  return { readings, error }
}

Validate the response in the API route before it reaches this hook. For a push-based feed, call the same mergeReadings helper when a WebSocket or event subscription delivers a new batch.

3. Show the latest reading, trend, and age

Put the view component in CurrentGlucose.tsx:

'use client'

import type { CGMTrend, GlucoseReading } from '@glucoseiq/core'
import { useGlucoseLive } from '@glucoseiq/react'

const liveOptions = {
  windowMin: 15,
  refreshMs: 30_000,
} as const

const arrows: Record<CGMTrend, string> = {
  rapidRising: '⇈',
  rising: '↑',
  slightlyRising: '↗',
  flat: '→',
  slightlyFalling: '↘',
  falling: '↓',
  rapidFalling: '⇊',
  unknown: '·',
}

interface CurrentGlucoseProps {
  readings: GlucoseReading[]
}

export function CurrentGlucose({ readings }: CurrentGlucoseProps) {
  const live = useGlucoseLive(readings, liveOptions)

  if (!live.latest || live.minutesSince === null) {
    return <p>No current glucose reading is available.</p>
  }

  if (live.minutesSince < 0) {
    return (
      <p role="status">
        The latest reading time is ahead of the current clock.
      </p>
    )
  }

  const age = Math.round(live.minutesSince)
  const isStale = age > 10
  const rate = Number.isFinite(live.trend.rocPerMin)
    ? `${live.trend.rocPerMin.toFixed(1)} mg/dL/min`
    : 'Rate unavailable'
  const trendLabel = live.trend.trend.replace(/([a-z])([A-Z])/gu, '$1 $2').toLowerCase()
  return (
    <section aria-label="Current glucose" aria-live="polite">
      <p>{isStale ? 'Sensor data may be stale' : 'Current glucose'}</p>
      <strong>{live.latest.value} {live.latest.unit}{' '}
        <span aria-hidden="true">{arrows[live.trend.trend]}</span>
        <span style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0 }}>, trend {trendLabel}</span></strong>
      <p>{rate}</p>
      <time dateTime={live.latest.timestamp}>{age} min ago</time>
    </section>
  )
}

Then connect the feed to the component:

export function LiveGlucose() {
  const { readings, error } = useReadingFeed()

  return (
    <>
      {error && <p role="status">The feed could not be refreshed.</p>}
      <CurrentGlucose readings={readings} />
    </>
  )
}

How refreshMs works

refreshMs requests interval renders so “minutes ago” keeps updating while the sensor is quiet. It does not fetch data. New readings must still arrive through your polling, subscription, or server-data layer. Omit it to disable interval refresh. A provided value must be a whole number of milliseconds from 1 through 2_147_483_647; invalid values throw the typed core INVALID_OPTION error before a timer is scheduled.

Use the core without React

The same model works in a server, worker, native bridge, or another UI framework:

import {
  computeGlucoseTrend,
  latestReading,
  minutesSinceLastReading,
  type GlucoseReading,
} from '@glucoseiq/core'

const readings: GlucoseReading[] = [
  { value: 112, unit: 'mg/dL', timestamp: '2026-07-01T08:00:00Z' },
  { value: 118, unit: 'mg/dL', timestamp: '2026-07-01T08:05:00Z' },
]
const current = {
  latest: latestReading(readings),
  trend: computeGlucoseTrend(readings, { windowMin: 15 }),
  minutesSince: minutesSinceLastReading(readings, new Date()),
}

Pass a fixed now value in tests to reproduce staleness calculations.

Handle failure states

StateSuggested behavior
No usable readingShow “No reading” instead of a zero value
Fewer than two recent readingsShow the value with an unknown trend
Reading older than your product thresholdKeep the last value visible and label it stale
Reading time is ahead of the current clockShow a clock or source-timing problem instead of labeling the reading current
Feed request failsPreserve the last reading and show connection status separately
New reading arrivesReplace a reading with the same exact timestamp string, then cap history

Staleness is product policy, not a hard-coded GlucoseIQ rule. Choose the threshold based on how often the source sends readings and the claims your product is allowed to make.

On this page