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:
- Your data layer receives new CGM readings through polling, a subscription, or a server-side connector.
- 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:
| Function | Use it for |
|---|---|
latestReading | Find the newest fully usable reading without pre-sorting input |
computeGlucoseTrend | Fit a rate of change over a trailing window and classify the trend |
minutesSinceLastReading | Measure time since the latest reading using the current time or a supplied time |
classifyGlucoseTrend | Classify 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
| State | Suggested behavior |
|---|---|
| No usable reading | Show “No reading” instead of a zero value |
| Fewer than two recent readings | Show the value with an unknown trend |
| Reading older than your product threshold | Keep the last value visible and label it stale |
| Reading time is ahead of the current clock | Show a clock or source-timing problem instead of labeling the reading current |
| Feed request fails | Preserve the last reading and show connection status separately |
| New reading arrives | Replace 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.