diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx
index eb0951adf..52cf315ef 100644
--- a/src/components/clinician/clinician-view.tsx
+++ b/src/components/clinician/clinician-view.tsx
@@ -280,7 +280,13 @@ export function ClinicianView({
{/* ── the fenced tier, as the owner chose it ───────────── */}
-
+
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]) => (
+
+ ),
+ )}
);
}
diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts
index 31cf2ffa7..1e53342d7 100644
--- a/src/lib/ai/coach/about-me.ts
+++ b/src/lib/ai/coach/about-me.ts
@@ -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> = {
+ 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 = {
+ de: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
+ en: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
+};
+
+function fill(
+ template: string,
+ params: Record,
+): 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,
+): 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 = new Set(
+ 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.`;
}
diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
index db5658adb..eb8488a10 100644
--- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
+++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
@@ -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, {
diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts
index cf45a0c6b..f718d9c55 100644
--- a/src/lib/doctor-report-types.ts
+++ b/src/lib/doctor-report-types.ts
@@ -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
diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts
index e6eddc582..05f408e7d 100644
--- a/src/lib/doctor-report/clinical-records.ts
+++ b/src/lib/doctor-report/clinical-records.ts
@@ -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> {
- 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,
};
}
diff --git a/src/lib/fhir/resources/anamnesis.ts b/src/lib/fhir/resources/anamnesis.ts
index ee5dda207..46bf06c2e 100644
--- a/src/lib/fhir/resources/anamnesis.ts
+++ b/src/lib/fhir/resources/anamnesis.ts
@@ -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;
+}
diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts
index 28d71b46a..3a039e657 100644
--- a/src/lib/profile/life-circumstances.ts
+++ b/src/lib/profile/life-circumstances.ts
@@ -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 {
+ 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 = (kind: string): T | null =>
+ (facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
+ const packYears = computePackYears(
+ value("SMOKING_STATUS"),
+ value("TOBACCO_DETAIL"),
+ now,
+ );
+ const activity = value("ACTIVITY_HABIT");
+ const work = value("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,
+ };
+}
diff --git a/src/lib/profile/life-derived.ts b/src/lib/profile/life-derived.ts
index 6f8bfa317..ac5ca21c2 100644
--- a/src/lib/profile/life-derived.ts
+++ b/src/lib/profile/life-derived.ts
@@ -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;
+}
diff --git a/src/lib/profile/life-report-rows.ts b/src/lib/profile/life-report-rows.ts
new file mode 100644
index 000000000..b26a5c930
--- /dev/null
+++ b/src/lib/profile/life-report-rows.ts
@@ -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;
+
+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;
+}