require "json" module JosieHealth::Utils module TimezoneCity # Embed timezone data at compile time TZ_MAP_DATA = {{ read_file("#{__DIR__}/../data/tz_map.json") }} # City entry from tz_map.json struct Entry include JSON::Serializable property city : String property city_ascii : String? property lat : Float64? property lng : Float64? property pop : Int64 | Float64 | Nil property country : String @[JSON::Field(ignore: true)] property iso2 : String? = nil @[JSON::Field(ignore: true)] property iso3 : String? = nil property province : String? property timezone : String? def population : Int64 case p = pop when Int64 then p when Float64 then p.to_i64 else 0_i64 end end def display_city : String city_ascii || city end def valid? : Bool !timezone.nil? && !timezone.try(&.empty?) end end # Match result with scoring info struct Match getter city : String getter country : String getter timezone : String getter score : Int64 def initialize(@city, @country, @timezone, @score) end def to_h : Hash(String, String) {"city" => @city, "country" => @country, "timezone" => @timezone} end end @@data : Array(Entry)? = nil # Load timezone data (lazy loaded from embedded data, cached) def self.data : Array(Entry) @@data ||= load_data end # Reload data (useful for testing) def self.reload! @@data = load_data end # Find timezone matches for a query string # Returns top 5 matches sorted by relevance (population + match type) def self.lookup(query : String, limit : Int32 = 5) : Array(Match) return [] of Match if query.size < 2 query_lower = query.downcase.strip results = [] of Match data.each do |entry| next unless entry.valid? # Skip entries without timezone city = entry.display_city timezone = entry.timezone.not_nil! city_lower = city.downcase country_lower = entry.country.downcase pop = entry.population # Match priority: exact city > city prefix > city contains > country match score = if city_lower == query_lower pop + 100_000_000_i64 # Exact match bonus elsif city_lower.starts_with?(query_lower) pop + 10_000_000_i64 # Prefix match bonus elsif city_lower.includes?(query_lower) pop + 1_000_000_i64 # Contains match bonus elsif country_lower == query_lower || country_lower.starts_with?(query_lower) pop # Country match uses population only else next # No match end results << Match.new(city, entry.country, timezone, score) end # Sort by score descending, take top N results.sort_by! { |r| -r.score } results.first(limit) end # Find exact timezone for a city (returns first match) def self.find(city : String) : String? matches = lookup(city, 1) matches.first?.try(&.timezone) end private def self.load_data : Array(Entry) Array(Entry).from_json(TZ_MAP_DATA) rescue [] of Entry end end end