From db750ef246a493c026881547589d24021ceefc7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 00:58:34 +0200 Subject: [PATCH] feat(anamnesis): carry life circumstances into the coach context, the doctor report and FHIR --- src/components/clinician/clinician-view.tsx | 8 +- src/components/clinician/report-sections.tsx | 10 + src/lib/ai/coach/about-me.ts | 408 ++++++++++++++++-- .../clinical-records-notes-section.ts | 7 + src/lib/doctor-report-types.ts | 8 + src/lib/doctor-report/clinical-records.ts | 15 +- src/lib/fhir/resources/anamnesis.ts | 135 +++++- src/lib/profile/life-circumstances.ts | 83 ++++ src/lib/profile/life-derived.ts | 29 ++ src/lib/profile/life-report-rows.ts | 95 ++++ 10 files changed, 746 insertions(+), 52 deletions(-) create mode 100644 src/lib/profile/life-report-rows.ts 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; +}