marmar-context / src / domain / checks / dose-amount.ts
dose-amount.ts
Raw
import type { CheckMedicine } from './engine.js';
import { normalizeText, prnUnitKind, type Strength } from './normalize.js';

/** How much of an ingredient a medicine gives (M1), moved out of engine.ts unchanged to keep it under its size. */

/** Why a dose couldn't be worked out, for the dose_check wording. */
export type DoseProblem = 'topical' | 'no_strength' | 'strength_unreadable' | 'strength_not_per_ml' | 'no_frequency' | 'liquid' | 'unit_unknown' | 'prn_unit_unknown'
  | 'combination_mg';

export interface Dose {
  /** mg in each dose, when it can be worked out. */
  perDoseMg: number | null;
  /** mg in 24 hours, from the doses a day or the most a day. */
  perDayMg: number | null;
  problem: DoseProblem | null;
}


const LIQUID_FORMS = new Set(['liquid', 'syrup', 'suspension', 'solution', 'elixir', 'drops', 'injection']);
/** A name that says the medicine is a liquid ("Codeine linctus", "Paracetamol oral solution"): a saved form may be missing or "other" (R1). */
const LIQUID_NAME = /(?<![a-z])(?:syrups?|suspensions?|solutions?|elixirs?|linctus|mixtures?|drops|liquids?)(?![a-z])/;
/** Forms that are never a liquid, whatever the name or the measure says ("Nurofen Express Liquid Capsules", a spoon of Alabukun powder) (P1). */
const SOLID_FORMS = new Set(['tablet', 'capsule', 'caplet', 'powder', 'sachet', 'lozenge', 'suppository', 'patch', 'cream', 'gel', 'inhaler', 'spray']);
/** A name that says the medicine is a solid: "liquid capsules", "liquid caps", "soluble tablets" (P1). */
const SOLID_NAME = /(?<![a-z])(?:tablets?|tabs?|capsules?|caps|caplets?|softgels?|gelcaps?|sachets?|powders?|granules?)(?![a-z])/;
export const TOPICAL: Dose = { perDoseMg: null, perDayMg: null, problem: 'topical' };

/**
 * How much of an ingredient a medicine gives in a dose and in a day (M1),
 * from that ingredient's strength. "10 ml" of a "120 mg/5 ml" syrup is 240 mg,
 * never 10 x 120 mg. Anything we can't convert (a liquid without a strength
 * per ml, spoons, drops, "the most a day" with no unit when a dose is more
 * than one tablet) is reported as not checked. In a combination, a dose or
 * "the most a day" in mg or g could be any of its medicines, so it isn't
 * counted either.
 */
export function doseOf(medicine: CheckMedicine, strength: Strength | null, how: { combination: boolean; unreadable: boolean }): Dose {
  const unit = medicine.doseUnit ?? 'unit';
  const units = medicine.unitsPerDose;
  const form = (medicine.form ?? '').toLowerCase();
  const name = normalizeText(medicine.name);
  // A liquid form or a strength per ml makes a liquid. Otherwise a saved solid form always wins (P1); without one, a liquid
  // word in a name with no solid word ("Codeine linctus", never "Paracetamol liquid capsules"), or spoons or drops, do.
  const liquid = LIQUID_FORMS.has(form) || strength?.per === 'ml'
    || (!SOLID_FORMS.has(form) && ((LIQUID_NAME.test(name) && !SOLID_NAME.test(name)) || Boolean(medicine.liquidDose)));
  const none = (problem: DoseProblem): Dose => ({ perDoseMg: null, perDayMg: null, problem });
  // A strength entered but unreadable is said as such: "Add the strength" is only for one that is missing (gap 2).
  const noStrength: DoseProblem = how.unreadable ? 'strength_unreadable' : 'no_strength';
  let perDoseMg: number;
  // R1: spoons or drops of a liquid whose strength isn't per ml: the strength is what can't be used, so it is said.
  if (unit === 'unknown') return none(liquid && strength?.per === 'unit' ? 'strength_not_per_ml' : 'unit_unknown');
  if (unit === 'mg') {
    if (how.combination) return none('combination_mg');
    perDoseMg = units;
  } else if (unit === 'ml') {
    // M1: a liquid's strength that isn't per ml ("120 mg") can't give the mg in a dose in ml.
    if (strength?.per !== 'ml') return none(strength ? 'strength_not_per_ml' : noStrength);
    perDoseMg = strength.mg * units;
  } else {
    if (liquid) return none(strength?.per === 'unit' ? 'strength_not_per_ml' : 'liquid');
    if (strength?.per !== 'unit') return none(noStrength);
    perDoseMg = strength.mg * units;
  }
  if (medicine.timesPerDay) return { perDoseMg, perDayMg: perDoseMg * medicine.timesPerDay, problem: null };
  const most = medicine.prnMaxPerDay;
  if (!most) return { perDoseMg, perDayMg: null, problem: 'no_frequency' };
  // "The most a day" can count doses, tablets, ml or mg (prnMaxUnit).
  const perUnit = strength?.per === 'unit' && !liquid ? strength.mg : null;
  switch (prnUnitKind(medicine.prnMaxUnit)) {
    case 'dose': return { perDoseMg, perDayMg: perDoseMg * most, problem: null };
    case 'unit': return perUnit !== null ? { perDoseMg, perDayMg: perUnit * most, problem: null } : { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' };
    case 'ml': return strength?.per === 'ml' ? { perDoseMg, perDayMg: strength.mg * most, problem: null } : { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' };
    // A weight is converted: "at most 6 g a day" is 6000 mg (M-1). A combination's could be any of its medicines'.
    case 'mg': return how.combination ? { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' } : { perDoseMg, perDayMg: most, problem: null };
    case 'g': return how.combination ? { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' } : { perDoseMg, perDayMg: most * 1000, problem: null };
    case 'mcg': return how.combination ? { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' } : { perDoseMg, perDayMg: most / 1000, problem: null };
    // No unit: unambiguous only when a dose is one tablet (or one of whatever it comes in).
    case null: return unit === 'unit' && units === 1 ? { perDoseMg, perDayMg: perDoseMg * most, problem: null } : { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' };
    default: return { perDoseMg, perDayMg: null, problem: 'prn_unit_unknown' };
  }
}

export const DOSE_PROBLEM_PLAIN: Record<DoseProblem, string> = {
  topical: 'Doses of creams, gels, drops, sprays and patches aren\'t checked.',
  no_strength: 'Add the strength and how often to check the dose.',
  strength_unreadable: 'We couldn\'t read the strength entered, so the dose wasn\'t checked.',
  strength_not_per_ml: 'For a liquid we need the strength per 5 ml, so the dose wasn\'t checked.',
  no_frequency: 'Add how often it is taken, or the most a day, to check the dose.',
  liquid: 'For a liquid, add the strength per ml (for example 120 mg/5 ml) and the dose in ml to check it.',
  unit_unknown: 'We can only check doses in tablets, ml or mg, so this one wasn\'t checked.',
  prn_unit_unknown: 'Say whether the most a day is in tablets or doses to check it.',
  combination_mg: 'It has more than one medicine in it, so add the dose in tablets or ml to check it.',
};

/** Ruling A: the couldn't-check line for an adult's dose that can't be turned into mg (dose_unit_unknown), by why. A spoon is never taken as 5 ml. */
export const DOSE_NOT_IN_MG: Partial<Record<DoseProblem, (name: string) => string>> = {
  unit_unknown: (name) => `We couldn't work out how much ${name} is in each dose, so we couldn't check it against the label. Enter the dose in ml (a medicine spoon is 5 ml), tablets or mg.`,
  liquid: (name) => `We couldn't work out how much ${name} is in each dose: for a liquid we need the dose in ml. Enter the dose in ml to check it against the label.`,
  combination_mg: (name) => `We couldn't work out the dose of ${name}: it has more than one medicine in it, so we need the dose in tablets or ml to check it against the label.`,
  prn_unit_unknown: (name) => `We couldn't work out the most ${name} a day: say whether it is in tablets or doses to check it against the label.`,
};