feat(records): surface structured allergies and family history in the doctor report and AI context

The structured Allergy / FamilyHistoryEntry stores reached FHIR export
only: the doctor-report PDF shipped without the two sections every
clinical intake asks for first, and the Coach knew allergies only from
the free-text answer — a penicillin allergy recorded in the structured
records UI never reached either surface.

Doctor report: two new section toggles (allergies, familyHistory; both
default ON, same stance as labs) across the prefs schema, the export
selection, the aggregator, the PDF renderer, and the export panel. The
tables render stored fields only — substance/category/kind/severity/
reaction/status and relationship/condition/age-at-onset; the reaction
decrypts fail-soft per row and free-text notes are never read, matching
the illness-journal stance. OpenAPI regenerated for the extended
sections shape.

Coach: the about-me context block now appends the structured rows as
clearly labelled entries (authoritative over the free-text answer on
conflict); the projection is a pure composer with unit tests. New
section labels land in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 17:54:32 +02:00
1 parent 8d36e69f75
commit 9b4f3ce56d
15 files changed
+573 -14

No files matched your search

+8
View File
@@ -944,6 +944,10 @@ paths:
type: boolean
labs:
type: boolean
allergies:
type: boolean
familyHistory:
type: boolean
locale:
type: string
enum:
@@ -1076,6 +1080,10 @@ paths:
type: boolean
labs:
type: boolean
allergies:
type: boolean
familyHistory:
type: boolean
resourceTypes:
maxItems: 8
type: array
+15 -2
View File
@@ -187,7 +187,18 @@
"illnessColType": "Art",
"illnessColLifecycle": "Verlauf",
"illnessColOnset": "Beginn",
"illnessColResolved": "Abgeklungen"
"illnessColResolved": "Abgeklungen",
"allergiesTitle": "Allergien & Unverträglichkeiten",
"allergiesColSubstance": "Substanz",
"allergiesColCategory": "Kategorie",
"allergiesColKind": "Art",
"allergiesColSeverity": "Schweregrad",
"allergiesColReaction": "Reaktion",
"allergiesColStatus": "Status",
"familyHistoryTitle": "Familienanamnese",
"familyHistoryColRelationship": "Verwandtschaft",
"familyHistoryColCondition": "Erkrankung",
"familyHistoryColAgeAtOnset": "Alter bei Beginn"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -5061,7 +5072,9 @@
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code}).",
"labs": "Laborwerte"
"labs": "Laborwerte",
"allergies": "Allergien & Unverträglichkeiten",
"familyHistory": "Familienanamnese"
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
+15 -2
View File
@@ -187,7 +187,18 @@
"illnessColType": "Type",
"illnessColLifecycle": "Course",
"illnessColOnset": "Onset",
"illnessColResolved": "Resolved"
"illnessColResolved": "Resolved",
"allergiesTitle": "Allergies & intolerances",
"allergiesColSubstance": "Substance",
"allergiesColCategory": "Category",
"allergiesColKind": "Kind",
"allergiesColSeverity": "Severity",
"allergiesColReaction": "Reaction",
"allergiesColStatus": "Status",
"familyHistoryTitle": "Family history",
"familyHistoryColRelationship": "Relative",
"familyHistoryColCondition": "Condition",
"familyHistoryColAgeAtOnset": "Age at onset"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -5061,7 +5072,9 @@
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code}).",
"labs": "Lab results"
"labs": "Lab results",
"allergies": "Allergies & intolerances",
"familyHistory": "Family history"
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
+15 -2
View File
@@ -187,7 +187,18 @@
"illnessColType": "Tipo",
"illnessColLifecycle": "Curso",
"illnessColOnset": "Inicio",
"illnessColResolved": "Resuelta"
"illnessColResolved": "Resuelta",
"allergiesTitle": "Alergias e intolerancias",
"allergiesColSubstance": "Sustancia",
"allergiesColCategory": "Categoría",
"allergiesColKind": "Tipo",
"allergiesColSeverity": "Gravedad",
"allergiesColReaction": "Reacción",
"allergiesColStatus": "Estado",
"familyHistoryTitle": "Antecedentes familiares",
"familyHistoryColRelationship": "Parentesco",
"familyHistoryColCondition": "Afección",
"familyHistoryColAgeAtOnset": "Edad de inicio"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -5061,7 +5072,9 @@
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code}).",
"labs": "Resultados de análisis"
"labs": "Resultados de análisis",
"allergies": "Alergias e intolerancias",
"familyHistory": "Antecedentes familiares"
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
+15 -2
View File
@@ -187,7 +187,18 @@
"illnessColType": "Type",
"illnessColLifecycle": "Évolution",
"illnessColOnset": "Début",
"illnessColResolved": "Résolue"
"illnessColResolved": "Résolue",
"allergiesTitle": "Allergies et intolérances",
"allergiesColSubstance": "Substance",
"allergiesColCategory": "Catégorie",
"allergiesColKind": "Type",
"allergiesColSeverity": "Sévérité",
"allergiesColReaction": "Réaction",
"allergiesColStatus": "Statut",
"familyHistoryTitle": "Antécédents familiaux",
"familyHistoryColRelationship": "Lien de parenté",
"familyHistoryColCondition": "Affection",
"familyHistoryColAgeAtOnset": "Âge au début"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -5061,7 +5072,9 @@
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code}).",
"labs": "Résultats d'analyses"
"labs": "Résultats d'analyses",
"allergies": "Allergies et intolérances",
"familyHistory": "Antécédents familiaux"
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
+15 -2
View File
@@ -187,7 +187,18 @@
"illnessColType": "Tipo",
"illnessColLifecycle": "Decorso",
"illnessColOnset": "Inizio",
"illnessColResolved": "Risolta"
"illnessColResolved": "Risolta",
"allergiesTitle": "Allergie e intolleranze",
"allergiesColSubstance": "Sostanza",
"allergiesColCategory": "Categoria",
"allergiesColKind": "Tipo",
"allergiesColSeverity": "Gravità",
"allergiesColReaction": "Reazione",
"allergiesColStatus": "Stato",
"familyHistoryTitle": "Anamnesi familiare",
"familyHistoryColRelationship": "Parentela",
"familyHistoryColCondition": "Condizione",
"familyHistoryColAgeAtOnset": "Età di esordio"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -5061,7 +5072,9 @@
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code}).",
"labs": "Risultati degli esami"
"labs": "Risultati degli esami",
"allergies": "Allergie e intolleranze",
"familyHistory": "Anamnesi familiare"
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
+15 -2
View File
@@ -187,7 +187,18 @@
"illnessColType": "Typ",
"illnessColLifecycle": "Przebieg",
"illnessColOnset": "Początek",
"illnessColResolved": "Ustąpiła"
"illnessColResolved": "Ustąpiła",
"allergiesTitle": "Alergie i nietolerancje",
"allergiesColSubstance": "Substancja",
"allergiesColCategory": "Kategoria",
"allergiesColKind": "Rodzaj",
"allergiesColSeverity": "Nasilenie",
"allergiesColReaction": "Reakcja",
"allergiesColStatus": "Status",
"familyHistoryTitle": "Wywiad rodzinny",
"familyHistoryColRelationship": "Pokrewieństwo",
"familyHistoryColCondition": "Schorzenie",
"familyHistoryColAgeAtOnset": "Wiek zachorowania"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -5061,7 +5072,9 @@
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code}).",
"labs": "Wyniki badań"
"labs": "Wyniki badań",
"allergies": "Alergie i nietolerancje",
"familyHistory": "Wywiad rodzinny"
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
@@ -58,6 +58,8 @@ interface SectionState {
mood: boolean;
bmi: boolean;
labs: boolean;
allergies: boolean;
familyHistory: boolean;
}
const DEFAULT_SECTIONS: SectionState = {
@@ -79,6 +81,8 @@ const DEFAULT_SECTIONS: SectionState = {
mood: false, // privacy default
bmi: true,
labs: true,
allergies: true,
familyHistory: true,
};
function buildSelectionSections(s: SectionState) {
@@ -106,6 +110,8 @@ function buildSelectionSections(s: SectionState) {
mood: s.mood,
bmi: s.bmi,
labs: s.labs,
allergies: s.allergies,
familyHistory: s.familyHistory,
};
}
@@ -406,6 +412,16 @@ export function HealthRecordExportPanel() {
checked={sections.labs}
onToggle={() => toggle("labs")}
/>
<ToggleRow
label={t("settings.healthRecord.allergies")}
checked={sections.allergies}
onToggle={() => toggle("allergies")}
/>
<ToggleRow
label={t("settings.healthRecord.familyHistory")}
checked={sections.familyHistory}
onToggle={() => toggle("familyHistory")}
/>
</div>
{isPdfLike && (
@@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} }));
import {
clampPendingQuestions,
composeSelfContextText,
composeStructuredRecordsText,
deriveAgeYears,
PENDING_QUESTION_MAX_CHARS,
type SelfContext,
@@ -105,6 +106,52 @@ describe("composeSelfContextText", () => {
});
});
describe("composeStructuredRecordsText", () => {
it("stays null when both structured stores are empty", () => {
expect(composeStructuredRecordsText([], [], "en")).toBeNull();
});
it("renders allergies from stored fields only (severity, reaction, non-active status)", () => {
const text = composeStructuredRecordsText(
[
{
substance: "Penicillin",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
reaction: "Anaphylaxis",
},
{
substance: "Lactose",
type: "INTOLERANCE",
severity: null,
status: "RESOLVED",
reaction: null,
},
],
[],
"en",
);
expect(text).toContain("Penicillin (severe, reaction: Anaphylaxis)");
expect(text).toContain("Lactose (intolerance, resolved)");
});
it("renders family history with relationship labels and onset age (de)", () => {
const text = composeStructuredRecordsText(
[],
[
{ relationship: "MOTHER", condition: "Typ-2-Diabetes", ageAtOnset: 55 },
{ relationship: "FATHER", condition: "Hypertonie", ageAtOnset: null },
],
"de",
);
expect(text).toContain("Familienanamnese");
expect(text).toContain("Mutter: Typ-2-Diabetes (Beginn mit 55)");
expect(text).toContain("Vater: Hypertonie");
expect(text).not.toContain("Vater: Hypertonie (");
});
});
describe("clampPendingQuestions", () => {
it("caps at 3 questions and per-question length", () => {
const long = "x".repeat(PENDING_QUESTION_MAX_CHARS + 50);
+183 -2
View File
@@ -113,6 +113,155 @@ const GENDER_LABELS: Record<"de" | "en", Record<string, string>> = {
en: { MALE: "male", FEMALE: "female", OTHER: "non-binary" },
};
/**
* v1.27.x — structured-record projection (link between the Anamnese
* stores and the AI context). A user who records a penicillin allergy in
* the structured records UI — the app's most explicit input for exactly
* this — must not have a Coach that only knows the free-text answer.
* Stored fields only, rendered descriptively; the structured rows are the
* deliberate record, so the block labels them as such and they take
* precedence over the free-text answer on conflict.
*/
export interface StructuredAllergyForPrompt {
substance: string;
type: string;
severity: string | null;
status: string;
reaction: string | null;
}
export interface StructuredFamilyHistoryForPrompt {
relationship: string;
condition: string;
ageAtOnset: number | null;
}
const ALLERGY_SEVERITY_LABELS: Record<"de" | "en", Record<string, string>> = {
de: {
NONE: "ohne Beschwerden",
MILD: "leicht",
MODERATE: "mittel",
SEVERE: "schwer",
},
en: {
NONE: "no symptoms",
MILD: "mild",
MODERATE: "moderate",
SEVERE: "severe",
},
};
const ALLERGY_STATUS_LABELS: Record<"de" | "en", Record<string, string>> = {
de: { INACTIVE: "inaktiv", RESOLVED: "abgeklungen" },
en: { INACTIVE: "inactive", RESOLVED: "resolved" },
};
const FAMILY_RELATIONSHIP_LABELS: Record<
"de" | "en",
Record<string, string>
> = {
de: {
MOTHER: "Mutter",
FATHER: "Vater",
SISTER: "Schwester",
BROTHER: "Bruder",
DAUGHTER: "Tochter",
SON: "Sohn",
GRANDMOTHER_MATERNAL: "Großmutter (mütterlicherseits)",
GRANDFATHER_MATERNAL: "Großvater (mütterlicherseits)",
GRANDMOTHER_PATERNAL: "Großmutter (väterlicherseits)",
GRANDFATHER_PATERNAL: "Großvater (väterlicherseits)",
AUNT: "Tante",
UNCLE: "Onkel",
COUSIN: "Cousin/Cousine",
HALF_SIBLING: "Halbgeschwister",
OTHER: "Weitere Verwandte",
},
en: {
MOTHER: "mother",
FATHER: "father",
SISTER: "sister",
BROTHER: "brother",
DAUGHTER: "daughter",
SON: "son",
GRANDMOTHER_MATERNAL: "maternal grandmother",
GRANDFATHER_MATERNAL: "maternal grandfather",
GRANDMOTHER_PATERNAL: "paternal grandmother",
GRANDFATHER_PATERNAL: "paternal grandfather",
AUNT: "aunt",
UNCLE: "uncle",
COUSIN: "cousin",
HALF_SIBLING: "half-sibling",
OTHER: "other relative",
},
};
/**
* Compose the structured-records lines for the prompt context block.
* Descriptive, stored fields only — substance/kind/severity/reaction/
* status for allergies, relationship/condition/age-at-onset for family
* history. Returns `null` when both lists are empty so the caller can
* skip the block.
*/
export function composeStructuredRecordsText(
allergies: StructuredAllergyForPrompt[],
familyHistory: StructuredFamilyHistoryForPrompt[],
locale: string,
): string | null {
const de = locale === "de";
const lang = de ? "de" : "en";
const lines: string[] = [];
if (allergies.length > 0) {
const items = allergies.map((a) => {
const details: string[] = [];
if (a.type === "INTOLERANCE") {
details.push(de ? "Unverträglichkeit" : "intolerance");
}
if (a.severity) {
const label = ALLERGY_SEVERITY_LABELS[lang][a.severity];
if (label) details.push(label);
}
if (a.reaction) {
details.push((de ? "Reaktion: " : "reaction: ") + a.reaction);
}
const statusLabel = ALLERGY_STATUS_LABELS[lang][a.status];
if (statusLabel) details.push(statusLabel);
return details.length > 0
? `${a.substance} (${details.join(", ")})`
: a.substance;
});
lines.push(
(de
? "Dokumentierte Allergien/Unverträglichkeiten (strukturierte Einträge — bei Widerspruch maßgeblich): "
: "Recorded allergies/intolerances (structured entries — authoritative on conflict): ") +
items.join("; "),
);
}
if (familyHistory.length > 0) {
const items = familyHistory.map((f) => {
const rel =
FAMILY_RELATIONSHIP_LABELS[lang][f.relationship] ?? f.relationship;
const onset =
f.ageAtOnset !== null
? de
? ` (Beginn mit ${f.ageAtOnset})`
: ` (onset at ${f.ageAtOnset})`
: "";
return `${rel}: ${f.condition}${onset}`;
});
lines.push(
(de
? "Familienanamnese (strukturierte Einträge): "
: "Family history (structured entries): ") + items.join("; "),
);
}
return lines.length > 0 ? lines.join("\n") : null;
}
/**
* Compose the merged self-context text the prompt block quotes: profile
* facts first (age/gender from the User row — the single source, never
@@ -194,14 +343,33 @@ export async function getSelfContextTextForUser(
locale: string,
): Promise<string | null> {
try {
const [ctx, user] = await Promise.all([
const [ctx, user, allergyRows, familyRows] = await Promise.all([
getSelfContextForUser(userId),
prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true },
}),
// v1.27.x — structured records join the context block. Live rows
// only; the free-text notes are never selected. The reaction
// ciphertext decrypts fail-closed per row below.
prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
}),
prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
}),
]);
return composeSelfContextText(
const selfText = composeSelfContextText(
ctx,
{
ageYears: deriveAgeYears(user?.dateOfBirth ?? null),
@@ -209,6 +377,19 @@ export async function getSelfContextTextForUser(
},
locale,
);
const recordsText = composeStructuredRecordsText(
allergyRows.map((r) => ({
substance: r.substance,
type: r.type,
severity: r.severity,
status: r.status,
reaction: decryptOrNull(r.reactionEncrypted),
})),
familyRows,
locale,
);
if (!selfText && !recordsText) return null;
return [selfText, recordsText].filter(Boolean).join("\n");
} catch {
return null;
}
+95
View File
@@ -29,6 +29,7 @@ import type {
} from "@/generated/prisma/client";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { readNote } from "@/lib/crypto/note-cipher";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { userDayKey } from "@/lib/tz/resolver";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
@@ -320,6 +321,46 @@ export interface DoctorReportData {
/** ISO resolution instant, or null while ongoing. */
resolvedAt: string | null;
}> | null;
/**
* v1.27.x — structured allergy / intolerance records. Reference data,
* not time-windowed: populated whenever the `allergies` section toggle
* is ON (default) and the user recorded at least one live row. The
* reaction description is decrypted fail-soft per row (a key-rotation
* gap omits the value, never fails the report); the free-text note is
* NEVER read here — same stance as the illness journal. Null/absent
* otherwise; the PDF builder then skips the section. Optional so older
* fixtures still typecheck.
*/
allergies?: Array<{
/** Substance / allergen label as the user recorded it. */
substance: string;
/** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */
category: string;
/** Kind (ALLERGY / INTOLERANCE). */
type: string;
/** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */
severity: string | null;
/** Status (ACTIVE / INACTIVE / RESOLVED). */
status: string;
/** Decrypted reaction description, or null (unset or key gap). */
reaction: string | null;
}> | null;
/**
* v1.27.x — structured family-history records. Reference data, not
* time-windowed: populated whenever the `familyHistory` section toggle
* is ON (default) and the user recorded at least one live row. Labels +
* onset age only — the free-text note is never read here. Null/absent
* otherwise; the PDF builder then skips the section. Optional so older
* fixtures still typecheck.
*/
familyHistory?: Array<{
/** Relationship (MOTHER / FATHER / SISTER / …). */
relationship: string;
/** Condition label as the user recorded it. */
condition: string;
/** The relative's age at onset (years), or null when unknown. */
ageAtOnset: number | null;
}> | null;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -1410,6 +1451,58 @@ export async function collectDoctorReportData(
illnessEpisodes = mapped.length > 0 ? mapped : null;
}
// v1.27.x — structured allergies + family history. Reference data, not
// time-windowed (a penicillin allergy does not expire with the report
// window), so no date filter. Riding the section toggles keeps the "you
// can deselect any section" contract; both default ON — a clinical
// report without an allergy section is the riskier default. The
// reaction description decrypts fail-soft per row; the free-text notes
// are never selected, matching the illness-journal stance.
let allergies: DoctorReportData["allergies"] = null;
if (sections.allergies) {
const rows = await prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
category: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
});
const mapped = rows.map((r) => {
let reaction: string | null = null;
if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) {
try {
reaction = decryptFromBytes(r.reactionEncrypted);
} catch {
reaction = null;
}
}
return {
substance: r.substance,
category: r.category,
type: r.type,
severity: r.severity,
status: r.status,
reaction,
};
});
allergies = mapped.length > 0 ? mapped : null;
}
let familyHistory: DoctorReportData["familyHistory"] = null;
if (sections.familyHistory) {
const rows = await prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
});
familyHistory = rows.length > 0 ? rows : null;
}
return {
period: {
days,
@@ -1464,6 +1557,8 @@ export async function collectDoctorReportData(
cycle,
labResults,
illnessEpisodes,
allergies,
familyHistory,
};
}
+115
View File
@@ -1604,6 +1604,121 @@ export function buildDoctorReportPdfDocument(
.finalY + 8;
}
// v1.27.x — structured allergy / intolerance records. Reference data
// (not time-windowed) the aggregator populates when the `allergies`
// toggle is ON (default) and rows exist. Stored fields only — substance,
// category, kind, severity, reaction, status — in the same calm factual
// table register as the illness section; no colour, no severity tint.
if (data.allergies && data.allergies.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.allergiesTitle"), margin, y);
y += 6;
const allergyRows = data.allergies.map((al) => [
al.substance,
t(`records.allergies.category.${al.category}`),
t(`records.allergies.type.${al.type}`),
al.severity ? t(`records.allergies.severity.${al.severity}`) : "—",
al.reaction ?? "—",
t(`records.allergies.status.${al.status}`),
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.allergiesColSubstance"),
t("doctorReport.allergiesColCategory"),
t("doctorReport.allergiesColKind"),
t("doctorReport.allergiesColSeverity"),
t("doctorReport.allergiesColReaction"),
t("doctorReport.allergiesColStatus"),
],
],
body: allergyRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// v1.27.x — structured family-history records. Reference data the
// aggregator populates when the `familyHistory` toggle is ON (default)
// and rows exist. Relationship + condition + age at onset only — the
// free-text note never reaches this surface.
if (data.familyHistory && data.familyHistory.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.familyHistoryTitle"), margin, y);
y += 6;
const familyRows = data.familyHistory.map((fh) => [
t(`records.family.relationship.${fh.relationship}`),
fh.condition,
fh.ageAtOnset !== null ? String(fh.ageAtOnset) : "—",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.familyHistoryColRelationship"),
t("doctorReport.familyHistoryColCondition"),
t("doctorReport.familyHistoryColAgeAtOnset"),
],
],
body: familyRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
// rendered ONLY when the user explicitly opted in. Clearly labelled and
// flagged as not clinically validated so a physician never mistakes it
@@ -92,6 +92,8 @@ describe("resolveDoctorReportPrefs", () => {
sleep: false,
cycle: false,
labs: false,
allergies: false,
familyHistory: false,
};
const out = resolveDoctorReportPrefs(current, { mood: false });
expect(out).toEqual({ ...current, mood: false });
@@ -33,6 +33,8 @@ export const doctorReportPrefsSchema = z
sleep: z.boolean(),
cycle: z.boolean(),
labs: z.boolean(),
allergies: z.boolean(),
familyHistory: z.boolean(),
})
.partial();
@@ -53,6 +55,8 @@ export interface DoctorReportPrefs {
sleep: boolean;
cycle: boolean;
labs: boolean;
allergies: boolean;
familyHistory: boolean;
}
/**
@@ -75,6 +79,12 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
// Lab results the user recorded for exactly this purpose — sharing
// bloodwork with a clinician. ON by default, like BP / weight.
labs: true,
// Structured allergy / intolerance records — the section every clinical
// intake asks for first. Reference data recorded to share, ON by default
// like labs.
allergies: true,
// Structured family history — same stance as allergies.
familyHistory: true,
};
/**
@@ -77,6 +77,11 @@ export const exportSectionsSchema = z
// Structured lab results — ON by default (recorded to share with a
// clinician, same stance as BP / weight).
labs: z.boolean(),
// Structured allergy / intolerance records — ON by default (the
// section every clinical intake asks for first; same stance as labs).
allergies: z.boolean(),
// Structured family history — ON by default, same stance as allergies.
familyHistory: z.boolean(),
})
.partial();
@@ -139,5 +144,7 @@ export function toDoctorReportPrefs(
// Cycle is opt-in: only true when explicitly requested (privacy).
cycle: s.cycle === true,
labs: s.labs ?? fallback.labs,
allergies: s.allergies ?? fallback.allergies,
familyHistory: s.familyHistory ?? fallback.familyHistory,
};
}