Step 5: Add the FINDRISC Questionnaire
What we are building
A server-published FHIR Questionnaire rendered by the Native SDK, with a local scoring function that accepts only the exact reviewed canonical URL and version. The saved QuestionnaireResponse remains the source record for patient answers.
What you should already have
- The sandbox project and Questionnaire identifiers from steps 1–2.
- A copy of the approved classic eight-item FINDRISC Questionnaire, obtained through an authorized source.
- Clinical-owner approval for its suitability, answer order, linkIds, score mapping, result categories, and patient-facing wording.
The official Finnish Diabetes Association guidance describes the instrument’s use, eligibility, risk bands, and source. It distinguishes FINDRISC from its newer nine-question combined risk-test flow. The original instrument publication is Lindström and Tuomilehto, Diabetes Care 2003. The current guide does not establish a license to reproduce question wording, answer labels, or translations: obtain permission or confirm an applicable license before distributing that material. Use approved translations only.
The implementation
Publish the Questionnaire as FHIR
Publish the approved artifact as a FHIR Questionnaire in the sandbox using your governed FHIR content workflow. Do not paste the risk instrument into this docs page or convert it to a custom app-only format.
The resource owner must preserve the exact item text, answer options, order, and any approved translations. For this tutorial’s local scorer, the approved classic eight-item resource must use these stable, app-mapped linkId values:
linkId used by the app | Reviewed score sequence by answer-option order |
|---|---|
age | 0, 2, 3, 4 |
bmi | 0, 1, 3 |
waist | 0, 3, 4 |
physicalActivity | 0, 2 |
fruitVegetableIntake | 0, 1 |
bloodPressureMedication | 0, 2 |
historyOfHighGlucose | 0, 5 |
familyDiabetesHistory | 0, 3, 5 |
The values above are scoring points, not question or answer text. The clinical owner must verify that this exact resource/version and its option order implement the selected FINDRISC version. Do not infer that answer order from a translation or from a resource title.
See the canonical Questionnaire and QuestionnaireResponse references.
Understand the FHIR lifecycle
FHIR Questionnaire
↓ (optional, only for approved FHIR prefill)
POST /fhir/R4/Questionnaire/:id/$populate
↓
render in QuestionnaireForm
↓
FHIR QuestionnaireResponse
↓
local reviewed FINDRISC score mapping
↓ (optional, only when Questionnaire is configured for extraction)
POST /fhir/R4/QuestionnaireResponse/:id/$extract
$populate is for answers that can be validly prefilled from the active Patient’s FHIR data. It does not infer diet, activity, prior high glucose, or family history. The SDK’s client.populateQuestionnaire(questionnaireId, parameters?) calls the current operation; the operation resolves the subject from the authenticated session. See populate a Questionnaire.
$extract is not a FINDRISC scoring API. Use it only if the approved Questionnaire is configured with the supported standard extraction metadata and the extracted Observation semantics are clinically appropriate. The Native SDK form can perform that extraction during its managed submission flow. See extract Observations.
Add the scoring function
Create src/features/findrisc/scoring.ts. It maps the selected FHIR answer back to its exact answerOption and uses the reviewed option index. It fails if a required item is missing, repeated, has an unexpected number of options, or belongs to another Questionnaire version.
export const FINDRISC_POINTS = {
age: [0, 2, 3, 4],
bmi: [0, 1, 3],
waist: [0, 3, 4],
physicalActivity: [0, 2],
fruitVegetableIntake: [0, 1],
bloodPressureMedication: [0, 2],
historyOfHighGlucose: [0, 5],
familyDiabetesHistory: [0, 3, 5],
} as const;
export type FindriscLinkId = keyof typeof FINDRISC_POINTS;
export type FindriscCategory =
| "low"
| "slightly-increased"
| "moderate"
| "high"
| "very-high";
type QuestionnaireItem = {
linkId: string;
answerOption?: object[];
item?: QuestionnaireItem[];
};
type ResponseItem = {
linkId: string;
answer?: object[];
item?: ResponseItem[];
};
type QuestionnaireShape = {
resourceType: "Questionnaire";
id?: string;
url?: string;
version?: string;
item?: QuestionnaireItem[];
};
type QuestionnaireResponseShape = {
resourceType: "QuestionnaireResponse";
questionnaire?: string;
status?: string;
item?: ResponseItem[];
};
type ReviewedInstrument = { url: string; version: string };
function flatten<T extends { linkId: string; item?: T[] }>(
items: T[] | undefined,
output = new Map<string, T>(),
): Map<string, T> {
for (const item of items ?? []) {
if (output.has(item.linkId)) {
throw new Error(`Duplicate Questionnaire linkId: ${item.linkId}`);
}
output.set(item.linkId, item);
flatten(item.item, output);
}
return output;
}
function valueX(record: object): [string, unknown] {
const values = Object.entries(record).filter(([key]) => /^value[A-Z]/.test(key));
if (values.length !== 1) throw new Error("Expected one FHIR value[x] field.");
const value = values[0];
if (!value) throw new Error("Expected one FHIR value[x] field.");
return [value[0], value[1]];
}
function stable(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(
([key, entry]) => [key, stable(entry)],
),
);
}
return value;
}
function sameValue(left: unknown, right: unknown): boolean {
return JSON.stringify(stable(left)) === JSON.stringify(stable(right));
}
export function classifyFindrisc(score: number): FindriscCategory {
if (!Number.isInteger(score) || score < 0 || score > 26) {
throw new RangeError("FINDRISC score must be an integer from 0 to 26.");
}
if (score <= 6) return "low";
if (score <= 11) return "slightly-increased";
if (score <= 14) return "moderate";
if (score <= 20) return "high";
return "very-high";
}
export function scoreFindrisc(
questionnaire: QuestionnaireShape,
response: QuestionnaireResponseShape,
reviewed: ReviewedInstrument,
) {
if (
questionnaire.url !== reviewed.url ||
questionnaire.version !== reviewed.version ||
!questionnaire.id ||
response.questionnaire !== `Questionnaire/${questionnaire.id}` ||
response.status !== "completed"
) {
throw new Error("The response does not match the reviewed completed Questionnaire.");
}
const questions = flatten(questionnaire.item);
const answers = flatten(response.item);
let total = 0;
for (const [linkId, points] of Object.entries(FINDRISC_POINTS)) {
const question = questions.get(linkId);
const answer = answers.get(linkId);
const options = question?.answerOption ?? [];
if (!question || !answer || options.length !== points.length) {
throw new Error(`Questionnaire content does not match reviewed item ${linkId}.`);
}
if (!answer.answer || answer.answer.length !== 1) {
throw new Error(`Expected one completed answer for ${linkId}.`);
}
const [selectedType, selectedValue] = valueX(answer.answer[0]);
const optionIndex = options.findIndex((option) => {
const [optionType, optionValue] = valueX(option);
return selectedType === optionType && sameValue(selectedValue, optionValue);
});
if (optionIndex < 0) throw new Error(`Answer is not an option for ${linkId}.`);
const score = points[optionIndex];
if (score === undefined) throw new Error(`No reviewed score exists for ${linkId}.`);
total += score;
}
return { total, category: classifyFindrisc(total) };
}
The sequences and categories correspond to the classic 0–26 FINDRISC version: 0–6 low, 7–11 slightly increased, 12–14 moderate, 15–20 high, and 21–26 very high. They must not be applied to the newer nine-question combined risk-test form. The official Finnish instructions identify the assessment as an estimate of future risk, not a diagnosis.
Test score bands and fail-closed behavior
Create src/features/findrisc/scoring.test.ts:
import {
classifyFindrisc,
FINDRISC_POINTS,
scoreFindrisc,
} from "./scoring";
const reviewed = {
url: "https://example.org/reviewed-classic-findrisc",
version: "1.0.0",
};
const questionnaire = {
resourceType: "Questionnaire" as const,
id: "findrisc-classic",
...reviewed,
item: Object.entries(FINDRISC_POINTS).map(([linkId, points]) => ({
linkId,
answerOption: points.map((_, index) => ({
valueString: `${linkId}:${index}`,
})),
})),
};
function completedResponse(useMaximumOptions: boolean) {
return {
resourceType: "QuestionnaireResponse" as const,
questionnaire: `Questionnaire/${questionnaire.id}`,
status: "completed",
item: Object.entries(FINDRISC_POINTS).map(([linkId, points]) => {
const index = useMaximumOptions ? points.length - 1 : 0;
return {
linkId,
answer: [{ valueString: `${linkId}:${index}` }],
};
}),
};
}
describe("FINDRISC category boundaries", () => {
it.each([
[0, "low"],
[6, "low"],
[7, "slightly-increased"],
[11, "slightly-increased"],
[12, "moderate"],
[14, "moderate"],
[15, "high"],
[20, "high"],
[21, "very-high"],
[26, "very-high"],
] as const)("classifies %i as %s", (score, expected) => {
expect(classifyFindrisc(score)).toBe(expected);
});
it.each([-1, 27, 1.5])("rejects out-of-range score %s", (score) => {
expect(() => classifyFindrisc(score)).toThrow(RangeError);
});
it("uses the expected total range for the eight-item score map", () => {
const items = Object.values(FINDRISC_POINTS);
expect(items.reduce((sum, points) => sum + Math.min(...points), 0)).toBe(0);
expect(items.reduce((sum, points) => sum + Math.max(...points), 0)).toBe(26);
});
it.each([
[false, 0, "low"],
[true, 26, "very-high"],
] as const)("scores the approved FHIR answer map", (maximum, total, category) => {
expect(
scoreFindrisc(questionnaire, completedResponse(maximum), reviewed),
).toEqual({ total, category });
});
it("fails closed when the reviewed Questionnaire version changes", () => {
expect(() =>
scoreFindrisc(
{ ...questionnaire, version: "unreviewed" },
completedResponse(false),
reviewed,
),
).toThrow("reviewed completed Questionnaire");
});
});
The boundary cases exercise every displayed category edge; the synthetic FHIR fixture exercises option matching at the minimum and maximum scores without reproducing clinical question text. Before release, also verify every answer option in the approved Questionnaire against the clinical owner’s signed-off mapping. Do not publish clinical labels in test fixtures or CMS translations unless the content license permits it.
Important Ovok decisions
- Ovok stores and renders the FHIR Questionnaire and QuestionnaireResponse; the score mapping belongs to this application because the current Ovok API does not calculate FINDRISC.
- No custom FHIR extension is invented for score calculation. The scorer reads the standard FHIR answer and answerOption values and matches canonical URL, version, resource ID, completion state, and stable linkIds.
$extractis optional and separate from scoring. Do not extract the score as a glucose Observation or imply it is a diagnosis.- The saved QuestionnaireResponse is authoritative for answers. Recalculate a displayed result using the same reviewed mapping rather than silently storing a second competing score resource.
Expected result
The approved Questionnaire is available to the Patient app, the scorer compiles against standard FHIR-shaped data, and Jest verifies all category boundaries and the 0–26 range.
Common errors and troubleshooting
- The form renders but scoring fails: compare resource URL, version, ID,
linkIds, answer count, and answerOption order against the signed-off artifact. - A Questionnaire version changes: do not keep using the old scoring map. Review and approve a new map before displaying a score.
- The response was saved but no Observation was extracted: extraction is optional and requires supported Questionnaire metadata and project configuration; it is not needed for a risk score.
- The total exceeds 26 or is fractional: reject it as invalid app data. Do not clamp or round a score.
- A localized instrument has reordered choices: use a validated instrument translation with its own verified option mapping; do not assume translated order is unchanged.
Previous / Next
Previous: choose the glucose device · Next: initialise Ovok, auth, and Bluetooth