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(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(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 } | { kind: 'total' } | { kind: 'none' } | { kind: 'unreadable' }; const UNREADABLE: StrengthReading = { kind: 'unreadable' }; type Amount = Extract; 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 | 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 }>>(); function chipReadings(chips: ProductStrengths): Array<{ per: 'unit' | 'ml'; mg: Record }> { const cached = CHIP_READINGS.get(chips); if (cached) return cached; const readings: Array<{ per: 'unit' | 'ml'; mg: Record }> = []; 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, b: Record) => 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; 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(); 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 }>): Record | null { const found = new Map>(); 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) => 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 }; }