feat(anamnesis): carry life circumstances into the coach context, the doctor report and FHIR

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:58:34 +02:00
1 parent f2be772eaf
commit db750ef246
10 files changed
+746 -52

No files matched your search

+7 -1
View File
@@ -280,7 +280,13 @@ export function ClinicianView({
{/* ── the fenced tier, as the owner chose it ───────────── */}
<FamilyHistorySection t={t} report={report} scope={scope} />
<AnamnesisSection t={t} report={report} scope={scope} />
<AnamnesisSection
t={t}
report={report}
scope={scope}
fmtNum={fmtNum}
fmtDate={fmtDate}
/>
<MoodSection t={t} report={report} scope={scope} fmtNum={fmtNum} />
<CycleSection
t={t}
@@ -13,6 +13,7 @@
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import { lifeReportRows } from "@/lib/profile/life-report-rows";
import { Fragment } from "react";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
@@ -473,10 +474,14 @@ export function AnamnesisSection({
t,
report,
scope,
fmtNum,
fmtDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtNum: (n: number) => string;
fmtDate: (iso: string) => string;
}) {
const anamnesis = report.anamnesis ?? null;
@@ -518,6 +523,11 @@ export function AnamnesisSection({
label={t("doctorReport.anamnesisShiftSchedule")}
value={factValue("SHIFT_SCHEDULE", anamnesis?.shiftSchedule ?? null)}
/>
{lifeReportRows(anamnesis?.life, t, fmtNum, fmtDate).map(
([label, value]) => (
<StatRow key={label} label={label} value={value} />
),
)}
</LeafSection>
);
}
+362 -46
View File
@@ -40,7 +40,23 @@ import {
type HealthProfileFactKind,
type HealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
import {
decryptHealthProfileFactValue,
decryptLifeFactValue,
} from "@/lib/profile/health-facts";
import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived";
import { auditCCutoff } from "@/lib/mental-health/audit-c";
import {
LIFE_FACT_KINDS,
type ActivityHabitValue,
type FreeWeekdaysValue,
type HabitsValue,
type HealthGoalsValue,
type LifeFactKind,
type LifeFactValue,
type TobaccoDetailValue,
type WorkContextValue,
} from "@/lib/validations/life-circumstances";
export interface SelfContext {
aboutMe: string | null;
@@ -397,6 +413,286 @@ export function composeStructuredHealthFactsText(
return lines.length > 0 ? lines.join("\n") : null;
}
// ── Life circumstances (v1.43, #614) ─────────────────────────────────────────
type LifeLang = "de" | "en";
const LIFE_LABELS: Record<LifeLang, Record<string, string>> = {
de: {
selfReport: "Selbstauskunft, gilt seit",
products: "Tabakprodukte",
cigarettes: "Zigaretten pro Tag",
startYear: "Rauchbeginn",
quit: "Rauchstopp",
secondhand: "Passivrauch",
activity: "Mäßige bis anstrengende Bewegung",
activityValue: "{days} Tage pro Woche, je {minutes} Minuten",
sitting: "Sitzen an Werktagen (Stunden)",
situation: "Arbeitssituation",
nature: "Art der Arbeit",
night: "Nachtarbeit",
exposures: "Belastungen bei der Arbeit",
freeDays: "Freie Wochentage",
nap: "Regelmäßiger Mittagsschlaf",
snoring: "Schnarchen laut anderen",
diet: "Ernährungsweise",
dietSpecifics: "Besonderheiten",
lateMeals: "Mahlzeiten nach 21 Uhr pro Woche",
fruitVeg: "Portionen Obst und Gemüse pro Tag",
caffeine: "Koffeinhaltige Getränke pro Tag",
latestCaffeine: "Spätestes Koffein",
goals: "Gesundheitsziele",
auditC: "AUDIT-C (Screening, keine Diagnose)",
auditCValue: "{score} von 12 am {date}",
above: "Screening-Grenzwert {cutoff} erreicht",
below: "unter dem Screening-Grenzwert {cutoff}",
yes: "ja",
no: "nein",
},
en: {
selfReport: "self-report, valid since",
products: "Tobacco products",
cigarettes: "Cigarettes per day",
startYear: "Started smoking",
quit: "Stopped smoking",
secondhand: "Secondhand smoke",
activity: "Moderate to strenuous activity",
activityValue: "{days} days a week, {minutes} minutes each",
sitting: "Sitting on a workday (hours)",
situation: "Work situation",
nature: "Physical nature of work",
night: "Night work",
exposures: "Exposures at work",
freeDays: "Free weekdays",
nap: "Regular nap",
snoring: "Snoring reported by others",
diet: "Diet pattern",
dietSpecifics: "Specifics",
lateMeals: "Meals after 9 pm per week",
fruitVeg: "Fruit and vegetable portions per day",
caffeine: "Caffeinated drinks per day",
latestCaffeine: "Latest caffeine",
goals: "Health goals",
auditC: "AUDIT-C (screening, not a diagnosis)",
auditCValue: "{score} of 12 on {date}",
above: "at or above the screening cut-off of {cutoff}",
below: "below the screening cut-off of {cutoff}",
yes: "yes",
no: "no",
},
};
const WEEKDAY_NAMES: Record<LifeLang, readonly string[]> = {
de: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
en: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
};
function fill(
template: string,
params: Record<string, string | number>,
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
String(params[key] ?? ""),
);
}
/** One current life-circumstance revision as the prompt block reads it. */
export interface LifeFactForPrompt {
kind: LifeFactKind;
value: LifeFactValue;
validFrom: Date;
}
/** The newest AUDIT-C entry as the prompt block reads it: the sum and the cut-off verdict only. */
export interface AuditCForPrompt {
totalScore: number;
takenAt: Date;
cutoff: number | null;
aboveCutoff: boolean | null;
}
/**
* Compose the life-circumstance lines of the self-context block: each line
* says it is a self-report and since when it holds, the enum values stay in
* their wire form where a translation would add nothing (`fumes_solvents`
* reads fine to a model), and AUDIT-C is a sum with a screening verdict,
* never a label for the person. Null when there is nothing to say.
*/
export function composeLifeCircumstancesText(
facts: readonly LifeFactForPrompt[],
auditC: AuditCForPrompt | null,
locale: string,
): string | null {
const lang: LifeLang = locale === "de" ? "de" : "en";
const L = LIFE_LABELS[lang];
const yesNo = (v: boolean) => (v ? L.yes : L.no);
const since = (d: Date) => `(${L.selfReport} ${d.toISOString().slice(0, 7)})`;
const words = (list: readonly string[]) =>
list.map((w) => w.replace(/_/g, " ")).join(", ");
const lines: string[] = [];
for (const fact of facts) {
const parts: string[] = [];
switch (fact.kind) {
case "TOBACCO_DETAIL": {
const v = fact.value as TobaccoDetailValue;
if (v.products.length > 0)
parts.push(`${L.products}: ${words(v.products)}`);
if (v.cigarettesPerDay !== null)
parts.push(`${L.cigarettes}: ${v.cigarettesPerDay}`);
if (v.startYear !== null) parts.push(`${L.startYear}: ${v.startYear}`);
if (v.quitMonth !== null) parts.push(`${L.quit}: ${v.quitMonth}`);
if (v.secondhandSmoke !== null)
parts.push(`${L.secondhand}: ${yesNo(v.secondhandSmoke)}`);
break;
}
case "ACTIVITY_HABIT": {
const v = fact.value as ActivityHabitValue;
if (
v.moderateDaysPerWeek !== null &&
v.moderateMinutesPerDay !== null
) {
parts.push(
`${L.activity}: ${fill(L.activityValue, {
days: v.moderateDaysPerWeek,
minutes: v.moderateMinutesPerDay,
})}`,
);
}
if (v.sittingHoursWorkday !== null)
parts.push(`${L.sitting}: ${v.sittingHoursWorkday}`);
break;
}
case "WORK_CONTEXT": {
const v = fact.value as WorkContextValue;
if (v.situation !== null)
parts.push(`${L.situation}: ${words([v.situation])}`);
if (v.physicalNature !== null)
parts.push(`${L.nature}: ${words([v.physicalNature])}`);
if (v.nightWork !== null)
parts.push(`${L.night}: ${yesNo(v.nightWork)}`);
if (v.exposures.length > 0)
parts.push(`${L.exposures}: ${words(v.exposures)}`);
break;
}
case "FREE_WEEKDAYS": {
const v = fact.value as FreeWeekdaysValue;
parts.push(
`${L.freeDays}: ${[...v.days]
.sort((a, b) => a - b)
.map((d) => WEEKDAY_NAMES[lang][d - 1])
.join(", ")}`,
);
break;
}
case "HABITS": {
const v = fact.value as HabitsValue;
if (v.napRegularly !== null)
parts.push(`${L.nap}: ${yesNo(v.napRegularly)}`);
if (v.snoringReported !== null)
parts.push(`${L.snoring}: ${yesNo(v.snoringReported)}`);
if (v.dietPattern !== null)
parts.push(`${L.diet}: ${words([v.dietPattern])}`);
if (v.dietSpecifics.length > 0)
parts.push(`${L.dietSpecifics}: ${words(v.dietSpecifics)}`);
if (v.lateMealsPerWeek !== null)
parts.push(`${L.lateMeals}: ${v.lateMealsPerWeek}`);
if (v.fruitVegPortionsPerDay !== null)
parts.push(`${L.fruitVeg}: ${v.fruitVegPortionsPerDay}`);
if (v.caffeineDrinksPerDay !== null)
parts.push(`${L.caffeine}: ${v.caffeineDrinksPerDay}`);
if (v.latestCaffeine !== null)
parts.push(`${L.latestCaffeine}: ${words([v.latestCaffeine])}`);
break;
}
case "HEALTH_GOALS": {
const v = fact.value as HealthGoalsValue;
if (v.goals.length > 0) parts.push(`${L.goals}: ${words(v.goals)}`);
break;
}
}
if (parts.length > 0)
lines.push(`${parts.join("; ")} ${since(fact.validFrom)}`);
}
if (auditC) {
const verdict =
auditC.cutoff === null || auditC.aboveCutoff === null
? ""
: `, ${fill(auditC.aboveCutoff ? L.above : L.below, { cutoff: auditC.cutoff })}`;
lines.push(
`${L.auditC}: ${fill(L.auditCValue, {
score: auditC.totalScore,
date: auditC.takenAt.toISOString().slice(0, 10),
})}${verdict}`,
);
}
return lines.length > 0 ? lines.join("\n") : null;
}
/**
* Load the life-circumstance context a prompt may see. Only the kinds whose
* AI section is switched on are read, so a section left out is never
* decrypted for a prompt; AUDIT-C rides the alcohol switch and needs no
* decryption (the sum is stored in the clear, like the screeners').
*/
async function loadLifeContextForPrompt(
userId: string,
included: ReadonlySet<string>,
): Promise<{ facts: LifeFactForPrompt[]; auditC: AuditCForPrompt | null }> {
const kinds = LIFE_FACT_KINDS.filter((kind) =>
included.has(LIFE_KIND_AI_SECTION[kind]),
);
const [rows, audit, user] = await Promise.all([
kinds.length > 0
? prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: { in: kinds },
validUntil: null,
supersededByRevisionId: null,
},
orderBy: { kind: "asc" },
select: { kind: true, valueEncrypted: true, validFrom: true },
})
: Promise.resolve([]),
included.has("ALCOHOL_PATTERN")
? prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { totalScore: true, takenAt: true, locale: true },
})
: Promise.resolve(null),
included.has("ALCOHOL_PATTERN")
? prisma.user.findUnique({
where: { id: userId },
select: { gender: true, locale: true },
})
: Promise.resolve(null),
]);
const facts = rows.flatMap((row) => {
const kind = row.kind as LifeFactKind;
const opened = decryptLifeFactValue(kind, row.valueEncrypted);
return opened.value
? [{ kind, value: opened.value, validFrom: row.validFrom }]
: [];
});
const cutoff = audit
? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en")
: null;
return {
facts,
auditC: audit
? {
totalScore: audit.totalScore,
takenAt: audit.takenAt,
cutoff: cutoff?.value ?? null,
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
}
: null,
};
}
/**
* Compose the merged self-context text the prompt block quotes: profile
* facts first (age/gender from the User row — the single source, never
@@ -486,51 +782,56 @@ export async function getSelfContextTextForUser(
(controlRow?.aiIncludedSections ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as HealthProfileAiSection[],
);
const includedStored: ReadonlySet<string> = new Set<string>(
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
);
const includedFactKinds = (
["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const
).filter((kind) => included.has(kind));
const [ctx, user, allergyRows, familyRows, factRows] = await Promise.all([
getSelfContextForUser(userId, prisma, included),
included.has("ABOUT_ME")
? prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true },
})
: Promise.resolve(null),
included.has("ALLERGIES")
? prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
})
: Promise.resolve([]),
included.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
})
: Promise.resolve([]),
includedFactKinds.length > 0
? prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: { in: includedFactKinds },
validUntil: null,
supersededByRevisionId: null,
},
orderBy: { kind: "asc" },
select: { kind: true, valueEncrypted: true },
})
: Promise.resolve([]),
]);
const [ctx, user, allergyRows, familyRows, factRows, life] =
await Promise.all([
getSelfContextForUser(userId, prisma, included),
included.has("ABOUT_ME")
? prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true },
})
: Promise.resolve(null),
included.has("ALLERGIES")
? prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
})
: Promise.resolve([]),
included.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
})
: Promise.resolve([]),
includedFactKinds.length > 0
? prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: { in: includedFactKinds },
validUntil: null,
supersededByRevisionId: null,
},
orderBy: { kind: "asc" },
select: { kind: true, valueEncrypted: true },
})
: Promise.resolve([]),
loadLifeContextForPrompt(userId, includedStored),
]);
const selfText = composeSelfContextText(
ctx,
@@ -568,8 +869,15 @@ export async function getSelfContextTextForUser(
}),
locale,
);
if (!selfText && !recordsText && !factsText) return null;
return [selfText, recordsText, factsText].filter(Boolean).join("\n");
const lifeText = composeLifeCircumstancesText(
life.facts,
life.auditC,
locale,
);
if (!selfText && !recordsText && !factsText && !lifeText) return null;
return [selfText, recordsText, factsText, lifeText]
.filter(Boolean)
.join("\n");
} catch (err) {
getEvent()?.addWarning(
`coach self-context text load failed: ${
@@ -683,7 +991,11 @@ INTERPRETATIONSREGELN:
- Das Alkoholkonsum-Muster nur als Kontext nutzen, ohne Menge oder Wirkung zu
erfinden.
- Feste oder wechselnde Schichtarbeit bei der Schlafzeit-Einordnung
berücksichtigen; unregelmäßiges Timing nicht allein als Problem darstellen.`;
berücksichtigen; unregelmäßiges Timing nicht allein als Problem darstellen.
- Ein AUDIT-C-Wert ist ein Screening-Ergebnis: nie als Diagnose oder
Alkoholproblem bezeichnen.
- Selbstauskünfte zu Bewegung, Schlaf und Ernährung ergänzen die Messdaten und
ersetzen sie nie; bei Widerspruch gelten die Messdaten.`;
}
return `
@@ -702,5 +1014,9 @@ INTERPRETATION RULES:
smoking cessation.
- Use an alcohol pattern as context only, without inventing amount or effect.
- Account for fixed or rotating shifts when interpreting sleep timing; do not
call irregular timing a problem on that fact alone.`;
call irregular timing a problem on that fact alone.
- An AUDIT-C score is a screening result: never call it a diagnosis or an
alcohol problem.
- Self-reported activity, sleep and diet habits add to the measurements and
never replace them; on a conflict the measurements win.`;
}
@@ -1,3 +1,4 @@
import { lifeReportRows } from "@/lib/profile/life-report-rows";
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
@@ -603,6 +604,12 @@ export function buildClinicalRecordsNotesSection(
t("doctorReport.anamnesisShiftSchedule"),
factValue("SHIFT_SCHEDULE", data.anamnesis.shiftSchedule),
],
...lifeReportRows(
data.anamnesis.life,
t,
(value) => num(value, 1),
fmtDate,
),
];
autoTable(doc, {
+8
View File
@@ -10,6 +10,7 @@
* `doctor-report-data.ts`.
*/
import type { LifeClinicalSummary } from "@/lib/profile/life-derived";
import type { GlucoseUnit } from "@/lib/glucose";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { MeasurementSource } from "@/generated/prisma/client";
@@ -463,6 +464,13 @@ export interface DoctorReportData {
unreadableFacts: Array<
"SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE"
>;
/**
* v1.43 (#614) — the clinical part of the life circumstances: pack-years,
* the newest AUDIT-C (a screening result with its cut-off and source),
* activity and work. Optional so a report built before v1.43 still
* renders.
*/
life?: LifeClinicalSummary;
} | null;
/**
* Emergency ("Notfalldaten") facts for the page-one section. Present only
+11 -4
View File
@@ -23,13 +23,15 @@ import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
import { loadLifeClinicalSummary } from "@/lib/profile/life-circumstances";
import {
emergencyProfileHasContent,
readEmergencyProfile,
} from "@/lib/profile/emergency-profile";
import type {
HealthProfileFactKind,
HealthProfileFactValue,
import {
HEALTH_PROFILE_FACT_KINDS,
type HealthProfileFactKind,
type HealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
/**
@@ -435,7 +437,7 @@ export async function loadEmergency(
export async function loadAnamnesis(
userId: string,
): Promise<NonNullable<DoctorReportData["anamnesis"]>> {
const [profile, factRows] = await Promise.all([
const [profile, factRows, life] = await Promise.all([
prisma.userHealthProfile.findUnique({
where: { userId },
select: { conditionsEncrypted: true },
@@ -443,11 +445,15 @@ export async function loadAnamnesis(
prisma.healthProfileFactRevision.findMany({
where: {
userId,
// The three closed kinds; the life-circumstance kinds arrive through
// `loadLifeClinicalSummary` with their own codec.
kind: { in: [...HEALTH_PROFILE_FACT_KINDS] },
validUntil: null,
supersededByRevisionId: null,
},
select: { kind: true, valueEncrypted: true },
}),
loadLifeClinicalSummary(userId),
]);
let conditions: string | null = null;
@@ -492,5 +498,6 @@ export async function loadAnamnesis(
DoctorReportData["anamnesis"]
>["shiftSchedule"],
unreadableFacts,
life,
};
}
+134 -1
View File
@@ -121,7 +121,7 @@ export function anamnesisObservationsFromReportData(
"SHIFT_SCHEDULE",
];
return kinds.map((kind, index) => {
const facts = kinds.map((kind, index): FhirObservation => {
const definition = FACT_DEFINITIONS[kind];
const value = values[kind];
const unreadable = anamnesis.unreadableFacts.includes(kind);
@@ -157,6 +157,7 @@ export function anamnesisObservationsFromReportData(
: {}),
};
});
return [...facts, ...lifeObservationsFromReportData(data)];
}
/** Raw narrative text; the document builder performs XHTML escaping once. */
@@ -182,5 +183,137 @@ export function anamnesisNarrativeFromReportData(
`Smoking status: ${factText("SMOKING_STATUS", anamnesis.smokingStatus)}.`,
`Alcohol pattern: ${factText("ALCOHOL_PATTERN", anamnesis.alcoholPattern)}.`,
`Shift schedule: ${factText("SHIFT_SCHEDULE", anamnesis.shiftSchedule)}.`,
...(anamnesis.life?.packYears != null
? [`Pack-years: ${anamnesis.life.packYears}.`]
: []),
...(anamnesis.life?.auditC
? [
`AUDIT-C (screening): ${anamnesis.life.auditC.totalScore} of 12 on ${anamnesis.life.auditC.takenAt.slice(0, 10)}.`,
]
: []),
].join(" ");
}
/**
* v1.43 (#614) — the clinical part of the life circumstances: AUDIT-C as a
* screening score (LOINC 75626-2), pack-years (SNOMED CT 401201003), and the
* self-reported activity and work context as text observations. Only present
* values emit; diet and goals never do (coach-only by design).
*/
function lifeObservationsFromReportData(
data: DoctorReportData,
): FhirObservation[] {
const life = data.anamnesis?.life;
if (!life) return [];
const out: FhirObservation[] = [];
const base = (id: string, category: string) => ({
resourceType: "Observation" as const,
id,
status: "final" as const,
category: [categoryConcept(category)],
subject: patientRef,
});
if (life.auditC) {
const a = life.auditC;
const verdict =
a.aboveCutoff === null || a.cutoffValue === null
? "No sex-specific cut-off applied (no binary sex in the profile)."
: `${a.aboveCutoff ? "At or above" : "Below"} the screening cut-off of ${a.cutoffValue} (${a.cutoffSourceLabel ?? "source not named"}).`;
out.push({
...base("obs-life-audit-c", "survey"),
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "75626-2",
display: "Total score [AUDIT-C]",
},
],
text: "AUDIT-C total score",
},
effectiveDateTime: a.takenAt,
valueInteger: a.totalScore,
note: [
{
text: `Screening result, not a diagnosis. ${verdict}`,
},
],
});
}
if (life.packYears !== null) {
out.push({
...base("obs-life-pack-years", "social-history"),
code: {
coding: [
{
system: SNOMED_SYSTEM,
code: "401201003",
display: "Cigarette pack-years",
},
],
text: "Cigarette pack-years",
},
valueQuantity: { value: life.packYears, unit: "pack-years" },
note: [
{
text: "Computed from self-reported cigarettes per day and years smoked.",
},
],
});
}
if (
life.activityMinutesPerWeek !== null ||
life.sittingHoursWorkday !== null
) {
const parts: string[] = [];
if (life.activityMinutesPerWeek !== null) {
parts.push(
`${life.activityMinutesPerWeek} minutes of moderate to strenuous activity per week (Exercise Vital Sign)`,
);
}
if (life.sittingHoursWorkday !== null) {
parts.push(`${life.sittingHoursWorkday} hours sitting on a workday`);
}
out.push({
...base("obs-life-activity", "social-history"),
code: { text: "Self-reported physical activity" },
valueString: `${parts.join("; ")}.`,
});
}
const work = life.work;
if (work) {
const parts = [
work.situation ? `situation: ${work.situation.replace(/_/g, " ")}` : null,
work.physicalNature
? `physical nature: ${work.physicalNature.replace(/_/g, " ")}`
: null,
work.nightWork === null
? null
: `night work: ${work.nightWork ? "yes" : "no"}`,
work.exposures.length > 0
? `exposures: ${work.exposures.map((e) => e.replace(/_/g, " ")).join(", ")}`
: null,
].filter((part): part is string => part !== null);
if (parts.length > 0) {
out.push({
...base("obs-life-work", "social-history"),
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "11341-5",
display: "History of Occupation",
},
],
text: "Self-reported work context",
},
valueString: `${parts.join("; ")}.`,
});
}
}
return out;
}
+83
View File
@@ -42,6 +42,7 @@ import {
screeningHints,
type FreeWeekdaysSpan,
type PackYears,
type LifeClinicalSummary,
type ScreeningHint,
} from "@/lib/profile/life-derived";
import {
@@ -56,6 +57,7 @@ import {
type LifeAiSection,
type LifeSectionKey,
type TobaccoDetailValue,
type WorkContextValue,
} from "@/lib/validations/life-circumstances";
const MS_PER_DAY = 86_400_000;
@@ -615,3 +617,84 @@ export async function removeAuditC(
await invalidateHealthProfileFactConsumers(userId, prisma);
return { id, removedAt: now.toISOString() };
}
// ── Clinical summary (doctor report, FHIR) ───────────────────────────────────
export async function loadLifeClinicalSummary(
userId: string,
now: Date = new Date(),
): Promise<LifeClinicalSummary> {
const [user, rows, audit] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { gender: true, locale: true },
}),
prisma.healthProfileFactRevision.findMany({
where: {
userId,
kind: {
in: [
"SMOKING_STATUS",
"TOBACCO_DETAIL",
"ACTIVITY_HABIT",
"WORK_CONTEXT",
],
},
validUntil: null,
supersededByRevisionId: null,
},
select: {
id: true,
kind: true,
valueEncrypted: true,
validFrom: true,
provenance: true,
createdAt: true,
},
}),
prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { id: true, takenAt: true, totalScore: true, locale: true },
}),
]);
const facts = rows.map(toLifeFactRevisionDto);
const value = <T>(kind: string): T | null =>
(facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
const packYears = computePackYears(
value<string>("SMOKING_STATUS"),
value<TobaccoDetailValue>("TOBACCO_DETAIL"),
now,
);
const activity = value<ActivityHabitValue>("ACTIVITY_HABIT");
const work = value<WorkContextValue>("WORK_CONTEXT");
const cutoff = audit
? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en")
: null;
return {
packYears: packYears.value,
auditC: audit
? {
id: audit.id,
takenAt: audit.takenAt.toISOString(),
totalScore: audit.totalScore,
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
cutoffValue: cutoff?.value ?? null,
cutoffSourceLabel: cutoff?.sourceLabel ?? null,
}
: null,
activityMinutesPerWeek: activityMinutesPerWeek(
activity?.moderateDaysPerWeek ?? null,
activity?.moderateMinutesPerDay ?? null,
).value,
sittingHoursWorkday: activity?.sittingHoursWorkday ?? null,
work: work
? {
situation: work.situation,
physicalNature: work.physicalNature,
nightWork: work.nightWork,
exposures: work.exposures,
}
: null,
};
}
+29
View File
@@ -17,6 +17,7 @@ import type {
LifeFactKind,
LifeSectionKey,
TobaccoDetailValue,
WorkContextValue,
} from "@/lib/validations/life-circumstances";
/** The kinds a section shows, in display order. Alcohol is AUDIT-C, not a fact. */
@@ -376,3 +377,31 @@ export function screeningHints(input: ScreeningInputs): ScreeningHint[] {
const region = screeningRegion(input.locale);
return [lungHint(input, region), aaaHint(input, region)];
}
// ── Clinical summary ─────────────────────────────────────────────────────────
/**
* What the doctor report and the FHIR export carry from the life
* circumstances: pack-years, the newest AUDIT-C as a screening result,
* activity and work. Diet and goals are deliberately absent (coach-only by
* design): they are context for a conversation, not clinical findings.
*/
export interface LifeClinicalSummary {
packYears: number | null;
auditC: {
id: string;
takenAt: string;
totalScore: number;
aboveCutoff: boolean | null;
cutoffValue: number | null;
cutoffSourceLabel: string | null;
} | null;
activityMinutesPerWeek: number | null;
sittingHoursWorkday: number | null;
work: {
situation: WorkContextValue["situation"];
physicalNature: WorkContextValue["physicalNature"];
nightWork: boolean | null;
exposures: WorkContextValue["exposures"];
} | null;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* v1.43 (#614) — the life-circumstance rows of the anamnesis section, one
* builder for the PDF and the clinician share view so both print the same
* lines. Self-reported values, labelled as such; AUDIT-C as a screening
* result with its cut-off and source, never as a finding. Diet and goals are
* not here on purpose (coach-only by design).
*
* Client-safe: pure, takes the translator and formatters it renders with.
*/
import type { LifeClinicalSummary } from "@/lib/profile/life-derived";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
export function lifeReportRows(
life: LifeClinicalSummary | undefined,
t: Translate,
fmtNum: (value: number) => string,
fmtDate: (iso: string) => string,
): Array<[string, string]> {
if (!life) return [];
const absent = t("doctorReport.anamnesisNotRecorded");
const rows: Array<[string, string]> = [];
rows.push([
t("doctorReport.lifePackYears"),
life.packYears === null ? absent : fmtNum(life.packYears),
]);
if (life.auditC) {
const a = life.auditC;
const score = t("doctorReport.lifeAuditCScore", {
score: a.totalScore,
date: fmtDate(a.takenAt),
});
const verdict =
a.aboveCutoff === null || a.cutoffValue === null
? t("doctorReport.lifeAuditCNoCutoff")
: t(
a.aboveCutoff
? "doctorReport.lifeAuditCAbove"
: "doctorReport.lifeAuditCBelow",
{ cutoff: a.cutoffValue, source: a.cutoffSourceLabel ?? "" },
);
rows.push([t("doctorReport.lifeAuditC"), `${score} ${verdict}`]);
} else {
rows.push([t("doctorReport.lifeAuditC"), absent]);
}
rows.push([
t("doctorReport.lifeActivity"),
life.activityMinutesPerWeek === null
? absent
: t("doctorReport.lifeActivityValue", {
minutes: fmtNum(life.activityMinutesPerWeek),
}),
]);
rows.push([
t("doctorReport.lifeSitting"),
life.sittingHoursWorkday === null
? absent
: t("doctorReport.lifeSittingValue", {
hours: fmtNum(life.sittingHoursWorkday),
}),
]);
const work = life.work;
const workParts: string[] = [];
if (work?.situation) {
workParts.push(t(`records.life.values.situation.${work.situation}`));
}
if (work?.physicalNature) {
workParts.push(
t(`records.life.values.physicalNature.${work.physicalNature}`),
);
}
if (work?.nightWork === true) workParts.push(t("doctorReport.lifeNightWork"));
if (work && work.exposures.length > 0) {
workParts.push(
t("doctorReport.lifeExposures", {
list: work.exposures
.map((exposure) => t(`records.life.values.exposures.${exposure}`))
.join(", "),
}),
);
}
rows.push([
t("doctorReport.lifeWork"),
workParts.length > 0 ? workParts.join(", ") : absent,
]);
return rows;
}