require "json" require "./data_files" module JosieHealth::LUT module AlcoholLUT ETHANOL_DENSITY = 0.789 # g/ml # Serving sizes in ml SERVING_SIZES = { "shot" => 50, "shots" => 50, "bottle" => 330, "bottles" => 330, "pint" => 568, "pints" => 568, "glass" => 150, "glasses" => 150, "can" => 330, "cans" => 330, "unit" => 1, "units" => 1, } # Category defaults CATEGORY_DEFAULTS = { "beer" => {abv: 5.0, default_ml: 330}, "cider" => {abv: 5.0, default_ml: 440}, "wine" => {abv: 12.5, default_ml: 150}, "spirits" => {abv: 40.0, default_ml: 50}, "liqueur" => {abv: 25.0, default_ml: 50}, "cocktail" => {abv: 12.0, default_ml: 200}, } @@brands : Hash(String, NamedTuple(abv: Float64, category: String, style: String, default_ml: Int32))? @@loaded = false # Load alcohol data from embedded JSON def self.load_data return if @@loaded begin json = DataFiles.alcohol @@brands = {} of String => NamedTuple(abv: Float64, category: String, style: String, default_ml: Int32) if brands_json = json["brands"]? brands_json.as_h.each do |name, data| @@brands.not_nil![name.to_s] = { abv: data["abv"].as_f, category: data["category"].as_s, style: data["style"].as_s, default_ml: data["default_ml"].as_i, } end end puts "Loaded #{@@brands.not_nil!.size} alcohol brands" @@loaded = true rescue ex puts "Error loading alcohol.json: #{ex.message}" @@brands = {} of String => NamedTuple(abv: Float64, category: String, style: String, default_ml: Int32) @@loaded = true end end # Lookup a brand by name def self.lookup_brand(name : String) : NamedTuple(abv: Float64, category: String, style: String, default_ml: Int32)? load_data normalized = name.downcase.gsub(/[\s\-_]/, "") @@brands.not_nil![normalized]? end # Check if a substance is an alcoholic beverage def self.is_alcohol?(name : String) : Bool lookup_brand(name) != nil end # Get category defaults def self.get_category_defaults(category : String) : NamedTuple(abv: Float64, default_ml: Int32)? CATEGORY_DEFAULTS[category.downcase]? end # Calculate alcohol grams from volume and ABV def self.calculate_grams(volume_ml : Float64, abv_percent : Float64) : Float64 (volume_ml * (abv_percent / 100.0) * ETHANOL_DENSITY).round(1) end # Parse dosage string like "2pints", "500ml", "1bottle" # Returns {count, serving_type, volume_ml} or nil if not parseable def self.parse_dosage(dosage : String) : NamedTuple(count: Int32, serving: String?, volume_ml: Int32)? normalized = dosage.downcase.strip # Try to match "Nunit" pattern (e.g., "2pints", "1bottle", "500ml") if match = normalized.match(/^(\d+(?:\.\d+)?)(ml|bottle|bottles|pint|pints|shot|shots|glass|glasses|can|cans|unit|units)?$/) count_str = match[1] serving = match[2]? # Handle decimal counts count_f = count_str.to_f count = count_f.to_i if serving == "ml" # Direct ml specification return {count: 1, serving: "ml", volume_ml: count_f.to_i} elsif serving && SERVING_SIZES.has_key?(serving) volume_ml = SERVING_SIZES[serving] * count return {count: count, serving: serving, volume_ml: volume_ml} elsif serving.nil? # Just a number, assume count of default serving return {count: count, serving: nil, volume_ml: 0} end end nil end # Parse ABV from string like "5%", "5.5%", "40" def self.parse_abv(abv_str : String) : Float64? normalized = abv_str.downcase.strip.gsub("%", "") normalized.to_f? end # Full alcohol dose calculation # Input: dosage string, substance name, optional ABV override # Returns: {grams, annotation} or nil def self.calculate_dose(dosage : String, substance : String, abv_override : Float64? = nil) : NamedTuple(grams: Float64, annotation: String)? parsed = parse_dosage(dosage) return nil unless parsed brand_info = lookup_brand(substance) # Determine ABV abv = abv_override abv ||= brand_info.try(&.[:abv]) # If no brand found and no ABV override, can't calculate return nil unless abv # Determine volume volume_ml = if parsed[:volume_ml] > 0 parsed[:volume_ml] elsif brand_info parsed[:count] * brand_info[:default_ml] else # Unknown brand, use beer default parsed[:count] * 330 end grams = calculate_grams(volume_ml.to_f, abv) # Build details string serving_str = parsed[:serving] ? "#{parsed[:count]}#{parsed[:serving]}" : parsed[:count].to_s details = "#{substance} #{serving_str} (#{volume_ml}ml #{abv}%) = #{grams}g" {grams: grams, details: details} end # Get all brand names def self.all_brands : Array(String) load_data @@brands.not_nil!.keys end end end