Skip to main content

7. Build the healthspan dashboard and trends

What we are building​

A member dashboard that brings five reflection domains and optional measured context together without blending their meaning or inventing a score.

What you should already have​

The implementation​

Load the typed resting-heart-rate measurements and the patient reflection history through their hooks. Sleep and step Observations use the HealthKit / Health Connect source coding systems rather than LOINC, so query those exact token codings through the FHIR client.

import { useEffect, useState } from "react";
import {
MeasurementTypeKey,
useClient,
useObservations,
usePatientQuestionnaireResponses,
} from "@ovok/core";
import { Text, View } from "react-native";

type ObservationRow = {
id?: string;
effectiveDateTime?: string;
effectivePeriod?: { start?: string; end?: string };
valueQuantity?: { value?: number; unit?: string };
};

const sleepCodes = [
["urn:ovok:healthkit:sample-type", "HKCategoryTypeIdentifierSleepAnalysis"],
["urn:ovok:health-connect:record-type", "SleepSession"],
] as const;

const stepCodes = [
["urn:ovok:healthkit:sample-type", "HKQuantityTypeIdentifierStepCount"],
["urn:ovok:health-connect:record-type", "Steps"],
] as const;

const restingHeartRateTypes = [MeasurementTypeKey.restingHeartRate] as const;

export function WellnessOverview({ patientId }: { patientId: string }) {
const client = useClient();
const [sleepSessions, setSleepSessions] = useState<ObservationRow[]>([]);
const [stepTotals, setStepTotals] = useState<ObservationRow[]>([]);
const [platformLoading, setPlatformLoading] = useState(true);
const [platformError, setPlatformError] = useState(false);

const observations = useObservations({
types: restingHeartRateTypes,
patientId,
count: 30,
sort: "-date",
});

const reflections = usePatientQuestionnaireResponses({
_sort: "-authored",
_count: 12,
});

useEffect(() => {
let current = true;
setPlatformLoading(true);
setPlatformError(false);

const findBySourceCode = (system: string, code: string) =>
client.searchResources("Observation", {
patient: `Patient/${patientId}`,
code: `${system}|${code}`,
_sort: "-date",
_count: 30,
});

void Promise.all([
...sleepCodes.map(([system, code]) => findBySourceCode(system, code)),
...stepCodes.map(([system, code]) => findBySourceCode(system, code)),
])
.then(([appleSleep, androidSleep, appleSteps, androidSteps]) => {
if (!current) return;
setSleepSessions([...appleSleep, ...androidSleep]);
setStepTotals([...appleSteps, ...androidSteps]);
})
.catch(() => {
if (current) setPlatformError(true);
})
.finally(() => {
if (current) setPlatformLoading(false);
});

return () => {
current = false;
};
}, [client, patientId]);

if (observations.loading || reflections.loading || platformLoading) {
return <Text>Loading your recent wellbeing data…</Text>;
}

if (observations.error || reflections.error) {
return <Text>Some information could not be loaded. Try again.</Text>;
}

return (
<View>
{platformError && (
<Text>Sleep and movement history could not be loaded. Try again.</Text>
)}

<Text>Weekly reflections · self-reported</Text>
{(reflections.data ?? []).map((response) => (
<Text key={response.id}>
Reflection from {response.authored ?? "an unknown date"}
</Text>
))}

<Text>Sleep sessions · imported intervals</Text>
{sleepSessions.map((observation, index) => (
<Text key={observation.id ?? `sleep-${index}`}>
{observation.effectivePeriod?.start ?? "Start unavailable"} – {observation.effectivePeriod?.end ?? "end unavailable"}
</Text>
))}

<Text>Steps · imported local-day totals</Text>
{stepTotals.map((observation, index) => (
<Text key={observation.id ?? `steps-${index}`}>
{observation.valueQuantity?.value ?? "Value unavailable"} {observation.valueQuantity?.unit ?? "steps"} · {observation.effectivePeriod?.start ?? observation.effectiveDateTime ?? "date unavailable"}
</Text>
))}

<Text>Resting heart rate · imported measurement</Text>
{(observations.measurements ?? []).map((measurement, index) => (
<Text key={index}>
{measurement.restingHeartRate ?? "Value unavailable"} bpm
</Text>
))}
</View>
);
}

This is the data-loading slice of the screen. Add one card for each QuestionnaireResponse domain answer and charts only after selecting display periods with the same meaning. The example queries both source systems so a member can still see data imported on a different device platform.

For trend lines, aggregate only values with the same meaning and valid time basis. Step observations are local-calendar-day totals; keep that day boundary when displaying or comparing them. Sleep rows show source intervals and any available FHIR components. Do not call elapsed interval minutes “time asleep,” and do not synthesize missing sleep stages.

Important Ovok decisions​

  • useObservations maps supported Observation categories to typed values; use it for resting heart rate.
  • The FHIR code token search matches source-coded sleep and step observations without pretending those codes are LOINC.
  • The patient QuestionnaireResponse hook returns saved answers. Render answers by stable linkId and retain the original resource for provenance.
  • A trend shows change over time, not cause. Do not say that one domain caused another to change.
  • Sleep and movement reflections remain useful even when health-platform access is declined.
  • Do not compute an overall healthspan score or label members as healthy/unhealthy.

Expected result​

The member sees weekly reflection history, sleep-session intervals, daily step totals and resting-heart-rate measurements in separate sections. Each section keeps its source and measurement semantics visible.

Common errors and troubleshooting​

SymptomCheck
Empty results look like an API failureShow empty and error states separately.
A coach screen lists other patientsStop and correct the server-side AccessPolicy; client-side filtering cannot secure the records.
Sleep interval is shown as time asleepKeep the interval label and show stage data only when the source supplies it.
Step totals shift at daylight-saving changesPreserve the platform's local-calendar-day grouping; do not reinterpret totals as 24-hour rates.
The app displays a composite scoreRemove it. No validated scoring method is part of this product.
Dates seem to shift around midnightPreserve the source period and display it in the member's locale/time zone without changing the underlying period.

Previous / next​