mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(anamnesis): carry life circumstances into the coach context, the doctor report and FHIR
This commit is contained in:
1 parent
f2be772eaf
commit
db750ef246
10 files changed
+746
-52
No files matched your search
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user