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<DoseStatus, string> = {
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,
};
});
}