import { INGREDIENT_BY_KEY, PRODUCTS } from './catalog.js';
import { normalizeText, parseStrength, strengthPieces, toMg, type StrengthPiece } from './normalize.js';
import { INGREDIENT_STRENGTHS, PRODUCT_STRENGTHS, type ProductStrengths, type Strengths } from '../medicines/strengths.js';
/**
* Reads a medicine's strength into each ingredient's amount, so the doses of
* a combination are counted like any other medicine's: co-codamol's
* paracetamol counts towards the 4,000 mg a day, with Panadol's.
*
* Never a guess. One medicine (caffeine aside) with one amount is read as
* parseStrength always read it. A combination's amounts are read only when it
* is clear which amount is which medicine's:
* - by the names written next to them ("paracetamol 500 mg, codeine 30 mg",
* "500mg paracetamol / 30mg codeine", "paracetamol/codeine 500/30 mg");
* - or by matching one of the product's own strengths (its chips, as the app
* offers them for that exact name), either way round: co-codamol "30/500 mg",
* "500mg/30mg", "30 mg / 500 mg" and "500/30" are all codeine 30 mg with
* paracetamol 500 mg, and "Tylenol with codeine" "300/30" is paracetamol
* 300 mg with codeine 30 mg.
* Anything else is unreadable, and the check says so: amounts that fit none of
* the product's strengths, or more than one way ("500/500"); one amount for a
* medicine with two that have a maximum (co-codamol "500 mg"); amounts with no
* names for a combination without chips ("Paracetamol + codeine" "500/30"); or
* names with no unit for amounts that aren't one of the product's strengths.
* A pack total (Augmentin "625 mg") is no one ingredient's amount.
*/
type Product = (typeof PRODUCTS)[number];
/** Each catalogue product by every name it is known by, written as the matcher reads names. */
const PRODUCT_BY_NAME = new Map<string, Product>(PRODUCTS.flatMap((product) => product.names.map((name) => [normalizeText(name), product] as const)));
/** Each product chip set by every exact name it is right for. */
const CHIPS_BY_NAME = new Map<string, ProductStrengths>(PRODUCT_STRENGTHS.flatMap((entry) => entry.names.map((name) => [normalizeText(name), entry] as const)));
const sameKeys = (a: string[], b: string[]) => a.length === b.length && a.every((key) => b.includes(key));
/**
* The one product a name stands for, and the names of it that matched: one
* product name, with nothing else in it. "Coartem and Panadol" or "Coartem
* with paracetamol" is not one product, so no product's strengths apply.
*/
export function productFor(matchedNames: string[], ingredients: string[]): { product: Product; names: string[] } | null {
const products = [...new Set(matchedNames.map((name) => PRODUCT_BY_NAME.get(name)).filter((product): product is Product => !!product))];
if (products.length !== 1 || !sameKeys(products[0].ingredients, ingredients)) return null;
return { product: products[0], names: matchedNames.filter((name) => PRODUCT_BY_NAME.get(name) === products[0]) };
}
/** The chips for exactly the product names matched, when one chip set covers them all. */
export function chipsFor(names: string[]): ProductStrengths | null {
const entries = [...new Set(names.map((name) => CHIPS_BY_NAME.get(name)))];
return entries.length === 1 && entries[0] ? entries[0] : null;
}
/** How the matcher read a medicine's name (matchIngredients). */
export interface ReadName { ingredients: string[]; matchedNames: string[]; unmatchedWords?: string[] }
/**
* The chips the app offers for a name (medicine facts), every form: the
* product's own, for exactly the names they are right for, else its one
* ingredient's. None unless every word of the name is recognised: an unknown
* word may be another ingredient.
*/
export function chipsOffered(name: ReadName): { entry: ProductStrengths | null; forms: Strengths } {
if (!name.ingredients.length || name.unmatchedWords?.length) return { entry: null, forms: {} };
const product = productFor(name.matchedNames, name.ingredients);
const entry = product ? chipsFor(product.names) : null;
if (entry) return { entry, forms: entry.forms };
return { entry: null, forms: name.ingredients.length === 1 ? INGREDIENT_STRENGTHS[name.ingredients[0]] ?? {} : {} };
}
/** How a strength was read: each ingredient's amount in mg (per tablet or other single unit, or per ml of a liquid), a pack total, nothing entered, or not readable. */
export type StrengthReading =
| { kind: 'read'; per: 'unit' | 'ml'; mg: Record<string, number> }
| { kind: 'total' }
| { kind: 'none' }
| { kind: 'unreadable' };
const UNREADABLE: StrengthReading = { kind: 'unreadable' };
type Amount = Extract<StrengthPiece, { kind: 'amount' }>;
const isAmount = (piece: StrengthPiece): piece is Amount => piece.kind === 'amount';
/**
* Each amount's weight in mg: its own unit ("500mg/30mg"), or one unit after
* the last amount for all of them when only "/" or "+" comes between them
* ("30/500 mg"); null for every amount when none has a unit ("500/30").
* Undefined when the units are mixed up ("500mg/30", "30 500 mg").
*/
function weights(pieces: StrengthPiece[]): Array<number | null> | undefined {
const amounts = pieces.filter(isAmount);
if (amounts.every((amount) => amount.unit)) return amounts.map((amount) => toMg(amount.value, amount.unit!));
if (amounts.every((amount) => !amount.unit)) return amounts.map(() => null);
const last = amounts[amounts.length - 1];
const run = pieces.slice(pieces.indexOf(amounts[0]), pieces.indexOf(last) + 1);
const joined = run.every((piece, index) => (index % 2 === 0 ? piece.kind === 'amount' : piece.kind === 'join'));
return last.unit && joined ? amounts.map((amount) => toMg(amount.value, last.unit!)) : undefined;
}
/** A product's strengths with one amount per ingredient, each read as ingredient key to mg, in the pack's order. */
const CHIP_READINGS = new Map<ProductStrengths, Array<{ per: 'unit' | 'ml'; mg: Record<string, number> }>>();
function chipReadings(chips: ProductStrengths): Array<{ per: 'unit' | 'ml'; mg: Record<string, number> }> {
const cached = CHIP_READINGS.get(chips);
if (cached) return cached;
const readings: Array<{ per: 'unit' | 'ml'; mg: Record<string, number> }> = [];
const order = chips.amountOrder ?? [];
for (const chip of new Set(Object.values(chips.forms).flat())) {
const written = strengthPieces(chip);
const mg = written ? weights(written.pieces) : undefined;
if (!written || !mg || mg.length !== order.length || mg.some((value) => value === null)) continue;
readings.push({ per: written.perMl ? 'ml' : 'unit', mg: Object.fromEntries(order.map((key, index) => [key, mg[index]! / (written.perMl ?? 1)])) });
}
CHIP_READINGS.set(chips, readings);
return readings;
}
const close = (a: number, b: number) => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
const sameAmounts = (a: Record<string, number>, b: Record<string, number>) =>
sameKeys(Object.keys(a), Object.keys(b)) && Object.keys(a).every((key) => close(a[key], b[key]));
function orderings(keys: string[]): string[][] {
if (keys.length <= 1) return [keys];
return keys.flatMap((key, index) => orderings([...keys.slice(0, index), ...keys.slice(index + 1)]).map((rest) => [key, ...rest]));
}
/**
* By the names beside the amounts: a name before each amount, after each, or
* all the names then all the amounts (or the other way round), each name a
* different ingredient of the medicine. The first name goes with the first
* amount, and so on. `blocks`: the names and the amounts were written as two
* separate groups ("codeine/paracetamol 500/30 mg"), so only their order says
* which amount is whose.
*/
function byNames(pieces: StrengthPiece[], values: number[], ingredients: string[]): { mg: Record<string, number>; blocks: boolean } | null {
const order = pieces.filter((piece) => piece.kind !== 'join');
const shape = order.map((piece) => (piece.kind === 'amount' ? 'a' : 'n')).join('');
const count = values.length;
const beside = [`${'na'.repeat(count)}`, 'an'.repeat(count)].includes(shape);
if (!beside && ![`${'n'.repeat(count)}${'a'.repeat(count)}`, `${'a'.repeat(count)}${'n'.repeat(count)}`].includes(shape)) return null;
const keys = order.flatMap((piece) => (piece.kind === 'name' ? [piece.ingredient] : []));
if (new Set(keys).size !== count || keys.some((key) => !ingredients.includes(key))) return null;
return { mg: Object.fromEntries(keys.map((key, index) => [key, values[index]])), blocks: !beside };
}
/**
* A number written alone ("500") as one of the medicine's own strengths, per
* tablet or other single unit, in that strength's unit (levothyroxine "50" is
* 50 mcg): its mg, or null when it is none of them, or more than one (M2).
*/
function ownStrengthMg(value: number, name: ReadName): number | null {
const found = new Set<number>();
for (const chip of Object.values(chipsOffered(name).forms).flat()) {
const written = strengthPieces(chip);
const amounts = written?.pieces.filter(isAmount) ?? [];
if (!written || written.perMl || amounts.length !== 1 || !amounts[0].unit) continue;
if (close(Number.parseFloat(amounts[0].value), value)) found.add(toMg(amounts[0].value, amounts[0].unit));
}
return found.size === 1 ? [...found][0] : null;
}
/** By matching one of the product's own strengths, in either order: read only when exactly one reading fits. */
function byChips(values: number[], chips: Array<{ mg: Record<string, number> }>): Record<string, number> | null {
const found = new Map<string, Record<string, number>>();
for (const chip of chips) {
const keys = Object.keys(chip.mg);
if (keys.length !== values.length) continue;
for (const order of orderings(keys)) {
if (!order.every((key, index) => close(values[index], chip.mg[key]))) continue;
const reading = Object.fromEntries(order.map((key, index) => [key, values[index]]));
found.set(JSON.stringify(Object.entries(reading).sort()), reading);
}
}
return found.size === 1 ? [...found.values()][0] : null;
}
/**
* A medicine's strength, read for the ingredients the matcher found in its
* name (`matchedNames` are the catalogue names that matched). "none" when
* nothing was entered.
*/
export function readStrength(strength: string | null | undefined, medicine: ReadName): StrengthReading {
if (!strength?.trim()) return { kind: 'none' };
// Caffeine (Panadol Extra, Alabukun) has no limit here: the one amount is the other medicine's, as its chips give it.
const dosed = medicine.ingredients.filter((key) => key !== 'caffeine');
const written = strengthPieces(strength);
const amounts = written?.pieces.filter(isAmount) ?? [];
const names = written?.pieces.flatMap((piece) => (piece.kind === 'name' ? [piece.ingredient] : [])) ?? [];
if (dosed.length <= 1 && amounts.length <= 1 && names.every((key) => key === dosed[0])) {
const single = parseStrength(strength);
if (single) return { kind: 'read', per: single.per, mg: dosed.length ? { [dosed[0]]: single.mg } : {} };
// M2: a number with no unit ("500") is read only as one of the medicine's own strengths: Panadol "500" is 500 mg,
// amlodipine "500" isn't read.
const bare = written && dosed.length === 1 && amounts.length === 1 && !amounts[0].unit && !written.perMl
? ownStrengthMg(Number.parseFloat(amounts[0].value), medicine) : null;
return bare !== null ? { kind: 'read', per: 'unit', mg: { [dosed[0]]: bare } } : UNREADABLE;
}
// No product has more than three amounts: more is never one medicine's strength.
if (!written || !amounts.length || amounts.length > 4) return UNREADABLE;
const mg = weights(written.pieces);
if (!mg) return UNREADABLE;
const product = productFor(medicine.matchedNames, medicine.ingredients);
const chips = product ? chipsFor(product.names) : null;
if (chips?.total && amounts.length === 1 && !names.length) return { kind: 'total' };
const per = written.perMl ? 'ml' as const : 'unit' as const;
// Amounts with no unit are compared with the product's strengths as mg.
const values = amounts.map((amount, index) => (mg[index] ?? Number.parseFloat(amount.value)) / (written.perMl ?? 1));
const unitless = mg.some((value) => value === null);
const fromChips = chips?.amountOrder ? chipReadings(chips).filter((chip) => chip.per === per) : [];
const named = names.length ? byNames(written.pieces, values, medicine.ingredients) : null;
let read = names.length ? named?.mg ?? null : byChips(values, fromChips);
const aChip = (reading: Record<string, number>) => fromChips.some((chip) => sameAmounts(chip.mg, reading));
// B1. Names with amounts that have no unit ("paracetamol/codeine 500/30"), or names and amounts written as two groups
// ("codeine/paracetamol 500/30 mg", where only their order says which is whose), are read only as one of the product's
// own strengths: co-codamol "codeine/paracetamol 500/30 mg" is never codeine 500 mg. A product with no chips never reads them.
if (read && named && (unitless || named.blocks) && !aChip(read)) read = null;
// Names beside amounts that are one of the product's own strengths only the other way round ("codeine 500 mg,
// paracetamol 30 mg") are a slip: never read the wrong way round.
if (read && named && !aChip(read) && byChips(values, fromChips)) read = null;
if (!read) return UNREADABLE;
// Every ingredient with a daily maximum needs its own amount: "paracetamol 500 mg" is all of Tylenol PM we check, not of co-codamol.
if (dosed.some((key) => INGREDIENT_BY_KEY.get(key)?.maxDailyMg && read![key] === undefined)) return UNREADABLE;
return { kind: 'read', per, mg: read };
}