package utils import ( "math" "strconv" "strings" "time" ) // ParseLatLng reads the coordinate strings this schema stores — customers, // customerlocations and tenantlocations all hold latitude/longitude as text // — and says whether they name a real place. // // "0,0" is rejected along with blanks: nothing on this platform is in the // Gulf of Guinea, and it is what an empty map picker saves. func ParseLatLng(lat, lng string) (float64, float64, bool) { la, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64) lo, err2 := strconv.ParseFloat(strings.TrimSpace(lng), 64) if err1 != nil || err2 != nil { return 0, 0, false } if la < -90 || la > 90 || lo < -180 || lo > 180 || (la == 0 && lo == 0) { return 0, 0, false } return la, lo, true } // HaversineKm is the great-circle distance between two points. func HaversineKm(lat1, lng1, lat2, lng2 float64) float64 { const earthRadiusKm = 6371.0 toRad := func(d float64) float64 { return d * math.Pi / 180 } dLat := toRad(lat2 - lat1) dLng := toRad(lng2 - lng1) a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2) return 2 * earthRadiusKm * math.Asin(math.Sqrt(a)) } // OpenNow reads tenantlocations.opentime/closetime ("09:00", "21:30", // "9:00 AM") against the wall clock. Unparsable or blank hours are treated as // open: a shop that never filled the field in should not vanish from the // list, and the ordering flow re-checks at checkout anyway. func OpenNow(open, closeAt string, now time.Time) bool { o, ok1 := parseClock(open) c, ok2 := parseClock(closeAt) if !ok1 || !ok2 || o == c { return true } cur := now.Hour()*60 + now.Minute() if o < c { return cur >= o && cur < c } // Past midnight: "20:00" – "02:00". return cur >= o || cur < c } func parseClock(s string) (int, bool) { s = strings.TrimSpace(s) if s == "" { return 0, false } for _, layout := range []string{"15:04", "15:04:05", "3:04 PM", "3:04PM", "03:04 PM", "15.04"} { if t, err := time.Parse(layout, strings.ToUpper(s)); err == nil { return t.Hour()*60 + t.Minute(), true } } return 0, false } // SearchTokens splits a label into the words worth matching on: lowercased, // punctuation stripped, single characters and pack-size noise dropped. "Milk // Bikis 100g" → ["milk", "bikis"]; the size is matched separately, if at all. func SearchTokens(label string) []string { var tokens []string seen := make(map[string]bool) for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9') }) { if len(raw) < 2 || isPackSize(raw) || seen[raw] { continue } seen[raw] = true tokens = append(tokens, raw) } return tokens } // isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit // glued on, or a bare number. func isPackSize(tok string) bool { digits := 0 for digits < len(tok) && tok[digits] >= '0' && tok[digits] <= '9' { digits++ } if digits == 0 { return false } switch tok[digits:] { case "", "g", "gm", "gms", "kg", "ml", "l", "ltr", "pcs", "pc", "x", "n": return true } return false }