module JosieHealth::Utils module Dosage # Parse a dosage string into amount and unit # Supports formats: "100mg", "0.5ml", ".38ml", "100" # Returns nil if invalid format def self.parse(dosage : String) : NamedTuple(amount: String, unit: String)? # Standard format: number followed by optional unit # Allows leading decimal (e.g., .38ml) if match = dosage.match(/^(\d*\.?\d+)(\w*)$/) amount = match[1] unit = match[2]? || "" return {amount: amount, unit: unit} end nil end # Parse dosage with math expressions like "47.5mg/2" or "100mg*0.5" # Returns calculated amount and unit, or nil if invalid def self.parse_math(dosage : String) : NamedTuple(amount: String, unit: String)? # Match: number, optional unit, operator, number, optional unit # Examples: 47.5mg/2, 100/2mg, 50*0.5mg, 200mg/4 if match = dosage.match(/^(\d+(?:\.\d+)?)(\w+)?([\/\*])(\d+(?:\.\d+)?)(\w+)?$/) num1 = match[1].to_f unit1 = match[2]? operator = match[3] num2 = match[4].to_f unit2 = match[5]? # Calculate result result = case operator when "/" num2 != 0 ? num1 / num2 : num1 when "*" num1 * num2 else num1 end # Use whichever unit was specified (prefer first, fallback to second) unit = unit1 || unit2 || "" # Format: remove trailing zeros, max 2 decimal places formatted = if result == result.to_i result.to_i.to_s else ("%.2f" % result).gsub(/\.?0+$/, "") end {amount: formatted, unit: unit} end end # Parse dosage, trying math expression first, then simple format def self.parse_any(dosage : String) : NamedTuple(amount: String, unit: String)? parse_math(dosage) || parse(dosage) end # Extract medication data from natural language text # Pattern: [verb] [route] # Examples: "50mg caffeine", "took 2g kratom oral" # Returns nil if no match found def self.extract_from_text(text : String) : NamedTuple(dosage: String, unit: String, substance: String, route: String?)? if match = text.match(/(?:took|snorted|smoked|ate|drank|injected|vaped)?\s*(\d+(?:\.\d+)?)\s*(mg|g|ml|ug|µg|mcg)?\s+(\w+)(?:\s+(\w+))?/i) dosage = match[1] unit = match[2]? || "mg" substance = match[3] route = match[4]? {dosage: dosage, unit: unit, substance: substance, route: route} end end # Format dosage for display (amount + unit) def self.format(amount : String, unit : String) : String "#{amount}#{unit}" end # Check if a string looks like a dosage # Matches: 100mg, 2.5g, 0.5ml, 47.5mg/2, 10mg/25mg, 100, etc. # This is used to detect malformed entries where dosage ended up as substance def self.looks_like_dosage?(text : String) : Bool normalized = text.strip.downcase # Empty or very short strings are not dosages return false if normalized.size < 1 # Pattern 1: Number with optional unit (100mg, 2.5g, 0.5ml, 100) return true if normalized.match(/^\d*\.?\d+(mg|g|ml|ug|µg|mcg|kg|l|oz|iu|units?)?$/i) # Pattern 2: Math expression with numbers and units (47.5mg/2, 100/2mg, 10mg/25mg) return true if normalized.match(/^\d+(?:\.\d+)?(?:mg|g|ml|ug|µg|mcg)?[\/\*]\d+(?:\.\d+)?(?:mg|g|ml|ug|µg|mcg)?$/i) # Pattern 3: Pure number (could be a dosage without unit) return true if normalized.match(/^\d+(?:\.\d+)?$/) false end # Check if a string looks like an HHMM timestamp (common user error) # Only matches bare 4-digit numbers without units: 0005, 0010, 0216, 1544 # Does NOT match doses with units like 1200mg, 1500mg (those are valid doses) # Used to detect when users confuse timestamp with dosage in ;td commands def self.looks_like_hhmm_timestamp?(text : String) : Bool normalized = text.strip.downcase # Must be exactly 4 digits with NO unit suffix # If there's a unit (mg, g, ml, etc), it's a real dosage not a timestamp return false unless normalized.match(/^\d{4}$/) # Parse as HHMM hours = normalized[0..1].to_i minutes = normalized[2..3].to_i # Valid time range: 00:00 - 23:59 hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 end # Validate and potentially correct swapped dosage/substance # Returns corrected values or nil if can't be corrected def self.validate_dose_substance(dosage : String, substance : String) : NamedTuple(dosage: String, substance: String, swapped: Bool)? dosage_looks_right = looks_like_dosage?(dosage) substance_looks_like_dose = looks_like_dosage?(substance) # Both look correct if dosage_looks_right && !substance_looks_like_dose return {dosage: dosage, substance: substance, swapped: false} end # Arguments appear swapped - dosage doesn't look like dosage but substance does if !dosage_looks_right && substance_looks_like_dose return {dosage: substance, substance: dosage, swapped: true} end # Both look like dosages - likely malformed, can't auto-correct if dosage_looks_right && substance_looks_like_dose return nil end # Neither looks like a dosage - unusual but accept as-is {dosage: dosage, substance: substance, swapped: false} end end end