import { unresolvedCoverage, type CoverageReview } from "./coverage"; import type { Assessment, Draft, Fact, Review, Source } from "./types"; import { conflicts } from "./review"; type Finding = Assessment["contextual"]["findings"][number]; export const MAX_SOURCES = 8; export const MAX_SOURCE_CHARS = 12000; const months = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "); /** Show an ISO date as "10 Oct 2026"; anything else is returned unchanged. */ export function formatDate(value: string): string { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return value; const month = months[Number(match[2]) - 1]; return month ? `${Number(match[3])} ${month} ${match[1]}` : value; } export const plural = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}`; export function initials(name: string): string { return ( name .trim() .split(/\s+/) .slice(0, 2) .map((part) => part[0] ?? "") .join("") .toUpperCase() || "?" ); } /** * Why the medication check cannot run yet, in the order a reviewer should fix things. * Returns null when the check may run. Mirrors the gates the server enforces. */ export function checkBlocker(input: { draft: Draft | null; review: Review; acknowledged: boolean; coverageReview?: CoverageReview; editing: boolean; }): string | null { const { draft, review, acknowledged, editing } = input; if (editing) return "Save or cancel your record edit first."; if (!draft) return "Read the records first."; const pending = draft.facts.filter((f) => !review[f.id]).length; if (pending) return `Include or exclude ${plural(pending, "more fact")}.`; const selected = draft.facts.filter((f) => review[f.id] === "include"); if (selected.some((f) => f.problem)) return "An included fact needs clarification. Exclude it or add a clarification."; const keys = conflicts(selected); if (keys.length) { const labels = [ ...new Set(selected.filter((f) => keys.includes(f.key)).map((f) => f.label)), ]; return `Records disagree about ${labels.join(", ")}. Exclude the entry you are not using.`; } if (!selected.some((f) => f.kind === "medicine")) return "Include at least one medication."; if ((draft.unparsed.length || draft.sourceReviewRequired) && !acknowledged) return "Confirm you checked the original notes."; const unresolved = unresolvedCoverage(draft.coverage ?? [], input.coverageReview ?? {}, draft.fingerprint); if (unresolved.length) return `Resolve medication coverage for ${unresolved.map((c) => c.text).join(", ")}.`; return null; } /** The reviewed facts that a finding rests on, so the reviewer can jump to their source lines. */ export function findingEvidence(assessment: Assessment, finding: Finding): Fact[] { return assessment.includedFacts.filter((fact) => fact.kind === "medicine" ? finding.medicines.some( (m) => m.id === fact.id || m.name.toLowerCase() === fact.label.toLowerCase() ) : finding.kind === "lab" ? fact.kind === "lab" && fact.id === finding.lab_evidence?.result.id : finding.kind === "allergy" ? fact.kind === "allergy" : fact.kind === "age" ); } /** The patient's first name, for "Personalized to Grace". */ export function patientName(assessment: Assessment): string { const name = assessment.includedFacts.find((f) => f.kind === "patient")?.label; return name?.trim().split(/\s+/)[0] || "this patient"; } /** The one-sentence answer that leads the findings screen. */ export function resultSummary(assessment: Assessment) { const before = assessment.baseline.findings.length; const after = assessment.contextual.findings.length; const added = assessment.addedRuleIds.length; const name = patientName(assessment); const headline = added ? `Personalized to ${name}: ${plural(added, "finding")} the medication list alone did not show.` : after ? `${plural(after, "finding")} for clinician review. The medication list alone showed the same.` : "No findings from the supported rules."; const subline = after === 0 ? "This does not mean the prescription is safe. Check what could not be assessed." : "Each finding links back to the record lines it came from. A clinician decides what to do."; return { before, after, added, headline, subline }; } /** "01-clinic_prescription.txt" becomes "Clinic prescription". */ function recordTitle(fileName: string): string { const title = fileName .replace(/\.txt$/i, "") .replace(/^\d+(?=[\s._-])/, "") .replace(/[-_.]+/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, 120); return title ? title[0].toUpperCase() + title.slice(1) : "Pasted note"; } /** Turn uploaded or pasted text into separate source records, each traceable on its own. */ export function recordsFromText( items: { name: string; text: string }[], existing: number, makeId: () => string ): { records: Source[]; error: string | null } { const usable = items.filter((item) => item.text.trim()); if (!usable.length) return { records: [], error: "Add at least one record: upload a .txt file or paste text." }; if (existing + usable.length > MAX_SOURCES) return { records: [], error: `A patient can have up to ${MAX_SOURCES} records. Remove ${ existing + usable.length - MAX_SOURCES } and try again.`, }; for (const item of usable) { if (item.text.length > MAX_SOURCE_CHARS) return { records: [], error: `${item.name || "A record"} is longer than ${MAX_SOURCE_CHARS.toLocaleString( "en" )} characters. Split it into smaller records.`, }; if (item.text.includes("\u0000")) return { records: [], error: `${item.name || "A record"} is not plain text.` }; } return { error: null, records: usable.map((item) => ({ id: `source-${makeId()}`, title: recordTitle(item.name), kind: "Synthetic note", date: "2026-10-23", text: item.text.trim(), })), }; } export type ProfileStatus = "used" | "not_assessed" | "missing"; export type ProfileItem = { id: string; label: string; value: string | null; status: ProfileStatus; note: string; fact?: Fact; }; const allergyUnknown = /^(unknown|not known|not sure|not recorded)$/i; /** * The personal factors this check could and could not use, in the order a * prescriber reads them. "Used" means a current rule reads it; recorded facts * no rule reads are labelled as such rather than implied to be checked. */ export function patientProfile(assessment: Assessment): ProfileItem[] { const facts = assessment.includedFacts; const first = (kind: Fact["kind"]) => facts.find((f) => f.kind === kind); const lab = (code: string) => facts.find( (f) => f.kind === "lab" && f.label.toLowerCase().startsWith(code + " "), ); const labValue = (f: Fact) => `${f.label.replace(/^\S+\s/, "")} ${f.detail.split(" · ")[0].replace(/\/1\.73m(2|²)$/i, "")}`; const labDate = (f: Fact) => formatDate(f.detail.split(" · ")[1] ?? ""); const items: ProfileItem[] = []; const age = first("age"); items.push( age ? { id: "age", label: "Age", value: age.label, status: "used", note: "Older-adult, child and dose rules", fact: age } : { id: "age", label: "Age", value: null, status: "missing", note: "Needed for older-adult, child and dose rules" }, ); const weight = first("weight"); items.push( weight ? { id: "weight", label: "Weight", value: weight.label, status: "used", note: "Dose limits under 50 kg", fact: weight } : { id: "weight", label: "Weight", value: null, status: "missing", note: "Needed for dose limits under 50 kg" }, ); const sex = first("sex"); if (sex) items.push({ id: "sex", label: "Sex", value: sex.label, status: "not_assessed", note: "Recorded; no current rule depends on it", fact: sex }); const egfr = lab("egfr"); items.push( egfr ? { id: "egfr", label: "Kidney function (eGFR)", value: labValue(egfr), status: "used", note: `${labDate(egfr)} · Kidney dose rules`, fact: egfr } : { id: "egfr", label: "Kidney function (eGFR)", value: null, status: "missing", note: "Needed for kidney dose rules" }, ); for (const [code, label, note] of [ ["potassium", "Potassium", "Potassium rules"], ["inr", "INR", "Warfarin rules"], ] as const) { const f = lab(code); if (f) items.push({ id: code, label, value: labValue(f), status: "used", note: `${labDate(f)} · ${note}`, fact: f }); } const allergies = facts.filter((f) => f.kind === "allergy"); const known = allergies.filter((f) => !allergyUnknown.test(f.label)); items.push( known.length ? { id: "allergies", label: "Allergies", value: known.map((f) => f.label).join(", "), status: "used", note: "Allergy and cross-reaction rules", fact: known[0] } : { id: "allergies", label: "Allergies", value: allergies.length ? "Unknown" : null, status: "missing", note: "Unknown is not the same as none" }, ); const pregnancy = first("pregnancy"); if (pregnancy) items.push({ id: "pregnancy", label: "Pregnancy", value: pregnancy.label, status: "not_assessed", note: pregnancy.label === "not pregnant" ? "Recorded" : "Recorded; not yet checked by current rules", fact: pregnancy, }); const conditions = facts.filter((f) => f.kind === "condition" && !allergyUnknown.test(f.label)); if (conditions.length) items.push({ id: "conditions", label: "Conditions", value: conditions.map((f) => f.label).join(", "), status: "not_assessed", note: "Recorded; not yet checked by current rules", fact: conditions[0], }); return items; } type DoseStatus = Assessment["contextual"]["dose_check"][number]["status"]; export const doseLabels: Record = { within_usual_range: "Within the usual adult maximum", above_limit: "Above the label maximum", check_with_pharmacist: "Usual dose may not apply", not_enough_info: "Not enough information", }; /** Each included medicine with its personalized dose status and the findings that name it. */ export function medicinePlan(assessment: Assessment) { const result = assessment.contextual; return assessment.includedFacts .filter((f) => f.kind === "medicine") .map((fact) => { const dose = result.dose_check.find((d) => d.medicine_id === fact.id) ?? result.dose_check.find( (d) => d.medicine.toLowerCase() === fact.label.toLowerCase(), ); const findings = result.findings.filter((f) => f.medicines.some( (m) => m.id === fact.id || m.name.toLowerCase() === fact.label.toLowerCase(), ), ); return { fact, status: (dose?.status ?? "not_enough_info") as DoseStatus, plain: dose?.plain ?? "This medicine was not dose-checked.", findings, }; }); }