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 five-domain model from step 5.
- Saved weekly responses from step 6.
- The canonical hooks useObservations, usePatientQuestionnaireResponses and useClient.
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
useObservationsmaps supported Observation categories to typed values; use it for resting heart rate.- The FHIR
codetoken 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
linkIdand 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
| Symptom | Check |
|---|---|
| Empty results look like an API failure | Show empty and error states separately. |
| A coach screen lists other patients | Stop and correct the server-side AccessPolicy; client-side filtering cannot secure the records. |
| Sleep interval is shown as time asleep | Keep the interval label and show stage data only when the source supplies it. |
| Step totals shift at daylight-saving changes | Preserve the platform's local-calendar-day grouping; do not reinterpret totals as 24-hour rates. |
| The app displays a composite score | Remove it. No validated scoring method is part of this product. |
| Dates seem to shift around midnight | Preserve the source period and display it in the member's locale/time zone without changing the underlying period. |