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, pack-size noise dropped. "Milk Bikis 100g" → // ["milk", "bikis"]; the size is matched separately, if at all. // // A single character is kept when it follows a word, because in this // catalogue that character is often the whole product: the "G" in "Parle G", // the "K" in "Special K". Dropping it made "Parle G" score the same against // "Parle-G Original Glucose Biscuits" as against "Parle Monaco Classic", and // the tie went to Monaco. It is still dropped when it stands alone — a // one-letter label is not a search — and bare multipliers ("2 x 50gm") are // never words. func SearchTokens(label string) []string { var tokens []string seen := make(map[string]bool) kept := 0 // multi-character tokens so far: a lone letter needs one var filler []string // packaging words, kept only if nothing else survives for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9') }) { if isPackSize(raw) || seen[raw] { continue } if len(raw) < 2 && (kept == 0 || isMultiplier(raw)) { continue } seen[raw] = true if isPackaging(raw) { filler = append(filler, raw) continue } if len(raw) > 1 { kept++ } tokens = append(tokens, raw) } // "Dettol bottle pack" is a scan of Dettol. Only when the label is nothing // but packaging does that packaging become the search. if len(tokens) == 0 { return filler } return tokens } // isPackaging is what the label says about the wrapper rather than the // product: "Dettol bottle pack", "Parle G biscuit pack". Lens reads these off // the packet and no catalogue name carries them, so every one of them used to // be a word the row had to contain — and "Dettol bottle pack" found no Dettol // at all. Treated like pack sizes: real words, just not the product's name. func isPackaging(tok string) bool { switch tok { case "pack", "packs", "packet", "packets", "bottle", "bottles", "box", "boxes", "jar", "jars", "tin", "tins", "pouch", "pouches", "carton", "cartons", "sachet", "sachets", "combo", "refill": return true } return false } // isMultiplier is the "x" of "2 x 50gm" and the "n" of a multipack — a single // character that joins sizes rather than naming a product. func isMultiplier(tok string) bool { return tok == "x" || tok == "n" } // FoldSeparators turns every run of punctuation into one space, so a label // typed without the brand's own punctuation still matches it: "Parle G" and // "Parle-G" both fold to "parle g". Lens reads letterforms off a packet, and // people type what they see, so the hyphen is not reliably either present or // absent on the way in. func FoldSeparators(s string) string { return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9') }), " ") } // TightenLabel removes separators outright rather than folding them, catching // the other way people write a hyphenated name: "ParleG" against "Parle-G". func TightenLabel(s string) string { return strings.ReplaceAll(FoldSeparators(s), " ", "") } // 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 }