From 58fdaa26caf6160b0e023ec1a6df49aa35ea7ea6 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 17 Oct 2025 16:43:27 +0200 Subject: [PATCH] Move to distance filtering to improve accuracy and speed --- backend/api/src/get-profiles.ts | 32 ++++++++++++++++++++-- backend/supabase/profiles.sql | 9 ++++++ common/src/api/schema.ts | 3 ++ common/src/filters.ts | 19 ++++++++++++- common/src/searches.ts | 14 +++++++++- web/components/filters/location-filter.tsx | 6 ++-- web/components/filters/use-filters.ts | 28 ++++++++++++------- web/components/profiles/profiles-home.tsx | 4 +-- 8 files changed, 95 insertions(+), 20 deletions(-) diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index 59bb863b..3191965a 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -22,6 +22,9 @@ export type profileQueryType = { is_smoker?: boolean | undefined, shortBio?: boolean | undefined, geodbCityIds?: String[] | undefined, + lat?: number | undefined, + lon?: number | undefined, + radius?: number | undefined, compatibleWithUserId?: string | undefined, skipId?: string | undefined, orderBy?: string | undefined, @@ -49,12 +52,17 @@ export const loadProfiles = async (props: profileQueryType) => { is_smoker, shortBio, geodbCityIds, + lat, + lon, + radius, compatibleWithUserId, orderBy: orderByParam = 'created_time', lastModificationWithin, skipId, } = props + const filterLocation = lat && lon && radius + const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : [] // console.debug('keywords:', keywords) @@ -90,6 +98,12 @@ export const loadProfiles = async (props: profileQueryType) => { (l.id.toString() != skipId) && (!geodbCityIds || (l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) && + (!filterLocation ||( + l.city_latitude && l.city_longitude && + Math.abs(l.city_latitude - lat) < radius / 69.0 && + Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) && + Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2) + )) && ((l.bio_length ?? 0) >= MIN_BIO_LENGTH) ) @@ -137,13 +151,13 @@ export const loadProfiles = async (props: profileQueryType) => { pref_relation_styles?.length && where( `pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`, - { pref_relation_styles } + {pref_relation_styles} ), pref_romantic_styles?.length && where( `pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`, - { pref_romantic_styles } + {pref_romantic_styles} ), !!wants_kids_strength && @@ -163,6 +177,18 @@ export const loadProfiles = async (props: profileQueryType) => { geodbCityIds?.length && where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}), + // miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0 + filterLocation && where(` + city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0) + AND $(target_lat) + ($(radius) / 69.0) + AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat))))) + AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat))))) + AND SQRT( + POWER(city_latitude - $(target_lat), 2) + + POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2) + ) <= $(radius) / 69.0 + `, {target_lat: lat, target_lon: lon, radius}), + skipId && where(`profiles.user_id != $(skipId)`, {skipId}), orderBy(`${tablePrefix}.${orderByParam} DESC`), @@ -174,7 +200,7 @@ export const loadProfiles = async (props: profileQueryType) => { LEFT JOIN ${userActivityJoin} WHERE profiles.id = $(after) )`, - { after } + {after} ), !shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}), diff --git a/backend/supabase/profiles.sql b/backend/supabase/profiles.sql index e144c5f2..cd0c2c5a 100644 --- a/backend/supabase/profiles.sql +++ b/backend/supabase/profiles.sql @@ -81,6 +81,15 @@ CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h CREATE INDEX IF NOT EXISTS idx_profiles_bio_length ON profiles (bio_length); +-- Fastest general-purpose index +DROP INDEX IF EXISTS profiles_lat_lon_idx; +CREATE INDEX profiles_lat_lon_idx ON profiles (city_latitude, city_longitude); + +-- Optional additional index for large tables / clustered inserts +DROP INDEX IF EXISTS profiles_lat_lon_brin_idx; +CREATE INDEX profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32); + + -- Functions and Triggers CREATE diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 39e9b666..2c43424d 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -355,6 +355,9 @@ export const API = (_apiTypeCheck = { is_smoker: z.coerce.boolean().optional(), shortBio: z.coerce.boolean().optional(), geodbCityIds: arraybeSchema.optional(), + lat: z.coerce.number().optional(), + lon: z.coerce.number().optional(), + radius: z.coerce.number().optional(), compatibleWithUserId: z.string().optional(), orderBy: z .enum(['last_online_time', 'created_time', 'compatibility_score']) diff --git a/common/src/filters.ts b/common/src/filters.ts index 909d54e5..b0504d0a 100644 --- a/common/src/filters.ts +++ b/common/src/filters.ts @@ -2,9 +2,18 @@ import {Profile, ProfileRow} from "common/love/profile"; import {cloneDeep} from "lodash"; import {filterDefined} from "common/util/array"; +// export type TargetArea = { +// lat: number +// lon: number +// radius: number +// } + export type FilterFields = { orderBy: 'last_online_time' | 'created_time' | 'compatibility_score' geodbCityIds: string[] | null + lat: number | null + lon: number | null + radius: number | null genders: string[] name: string | undefined shortBio: boolean | undefined @@ -18,6 +27,7 @@ export type FilterFields = { | 'pref_age_min' | 'pref_age_max' > + export const orderProfiles = ( profiles: Profile[], starredUserIds: string[] | undefined @@ -39,6 +49,9 @@ export const orderProfiles = ( } export const initialFilters: Partial = { geodbCityIds: undefined, + lat: undefined, + lon: undefined, + radius: undefined, name: undefined, genders: undefined, pref_age_max: undefined, @@ -51,4 +64,8 @@ export const initialFilters: Partial = { shortBio: undefined, orderBy: 'created_time', } -export type OriginLocation = { id: string; name: string } + + +export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[] + +export type OriginLocation = { id: string; name: string, lat: number, lon: number } diff --git a/common/src/searches.ts b/common/src/searches.ts index 5727c8c3..ff802714 100644 --- a/common/src/searches.ts +++ b/common/src/searches.ts @@ -25,6 +25,18 @@ export type locationType = { radius: number } +const skippedKeys = [ + 'pref_age_min', + 'pref_age_max', + 'geodbCityIds', + 'orderBy', + 'shortBio', + 'targetArea', + 'lat', + 'lon', + 'radius', +] + export function formatFilters(filters: Partial, location: locationType | null): String[] | null { const entries: String[] = [] @@ -53,7 +65,7 @@ export function formatFilters(filters: Partial, location: location const typedKey = key as keyof FilterFields if (value === undefined || value === null) return - if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy' || typedKey == 'shortBio') return + if (skippedKeys.includes(typedKey)) return if (Array.isArray(value) && value.length === 0) return if (initialFilters[typedKey] === value) return diff --git a/web/components/filters/location-filter.tsx b/web/components/filters/location-filter.tsx index 0372b94c..cefba3ca 100644 --- a/web/components/filters/location-filter.tsx +++ b/web/components/filters/location-filter.tsx @@ -75,7 +75,7 @@ export function LocationFilter(props: { if (!city) { setLocation(undefined) } else { - setLocation({ id: city.geodb_city_id, name: city.city }) + setLocation({ id: city.geodb_city_id, name: city.city, lat: city.latitude, lon: city.longitude }) setLastCity(city) } } @@ -123,7 +123,7 @@ function DistanceSlider(props: { }) { const { radius, setRadius } = props - const snapValues = [10, 50, 100, 200, 300] + const snapValues = [10, 50, 100, 200, 300, 500] const snapToValue = (value: number) => { const closest = snapValues.reduce((prev, curr) => @@ -158,7 +158,7 @@ function LocationResults(props: { }) { const { showAny, cities, onCitySelected, loading, className } = props - // delay loading animation by 150ms + // delay loading animation by 150 ms const [debouncedLoading, setDebouncedLoading] = useState(loading) useEffect(() => { if (loading) { diff --git a/web/components/filters/use-filters.ts b/web/components/filters/use-filters.ts index 72412a89..450b8387 100644 --- a/web/components/filters/use-filters.ts +++ b/web/components/filters/use-filters.ts @@ -1,10 +1,8 @@ import {Profile} from "common/love/profile"; import {useIsLooking} from "web/hooks/use-is-looking"; import {usePersistentLocalState} from "web/hooks/use-persistent-local-state"; -import {useCallback} from "react"; +import {useCallback, useEffect} from "react"; import {debounce, isEqual} from "lodash"; -import {useNearbyCities} from "web/hooks/use-nearby-locations"; -import {useEffectCheckEquality} from "web/hooks/use-effect-check-equality"; import {wantsKidsDatabase, wantsKidsDatabaseToWantsKidsFilter, wantsKidsToHasKidsFilter} from "common/wants-kids"; import {FilterFields, initialFilters, OriginLocation} from "common/filters"; import {MAX_INT, MIN_INT} from "common/constants"; @@ -13,9 +11,11 @@ export const useFilters = (you: Profile | undefined) => { const isLooking = useIsLooking() const [filters, setFilters] = usePersistentLocalState>( isLooking ? initialFilters : {...initialFilters, orderBy: 'created_time'}, - 'profile-filters-2' + 'profile-filters-4' ) + // console.log('filters', filters) + const updateFilter = (newState: Partial) => { const updatedState = {...newState} @@ -31,6 +31,8 @@ export const useFilters = (you: Profile | undefined) => { } } + // console.log('updating filters', updatedState) + setFilters((prevState) => ({...prevState, ...updatedState})) } @@ -54,11 +56,17 @@ export const useFilters = (you: Profile | undefined) => { OriginLocation | undefined | null >(undefined, 'nearby-origin-location') - const nearbyCities = useNearbyCities(location?.id, radius) + // const nearbyCities = useNearbyCities(location?.id, radius) + // + // useEffectCheckEquality(() => { + // updateFilter({geodbCityIds: nearbyCities}) + // }, [nearbyCities]) - useEffectCheckEquality(() => { - updateFilter({geodbCityIds: nearbyCities}) - }, [nearbyCities]) + useEffect(() => { + if (location?.lat && location?.lon) { + updateFilter({lat: location.lat, lon: location.lon, radius: radius}) + } + }, [location?.id, radius]); const locationFilterProps = { location, @@ -99,8 +107,8 @@ export const useFilters = (you: Profile | undefined) => { updateFilter(yourFilters) setRadius(100) debouncedSetRadius(100) // clear any pending debounced sets - if (you?.geodb_city_id && you.city) { - setLocation({id: you?.geodb_city_id, name: you?.city}) + if (you?.geodb_city_id && you.city && you.city_latitude && you.city_longitude) { + setLocation({id: you?.geodb_city_id, name: you?.city, lat: you?.city_latitude, lon: you?.city_longitude}) } } else { clearFilters() diff --git a/web/components/profiles/profiles-home.tsx b/web/components/profiles/profiles-home.tsx index 81dde77a..eb6aa787 100644 --- a/web/components/profiles/profiles-home.tsx +++ b/web/components/profiles/profiles-home.tsx @@ -6,7 +6,7 @@ import {useCompatibleProfiles} from 'web/hooks/use-profiles' import {getStars} from 'web/lib/supabase/stars' import {useCallback, useEffect, useRef, useState} from 'react' import {ProfileGrid} from 'web/components/profile-grid' -import {CompassLoadingIndicator, LoadingIndicator} from 'web/components/widgets/loading-indicator' +import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator' import {Title} from 'web/components/widgets/title' import {useGetter} from 'web/hooks/use-getter' import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' @@ -28,7 +28,7 @@ export function ProfilesHome() { locationFilterProps, } = useFilters(you ?? undefined); - const [profiles, setProfiles] = usePersistentInMemoryState(undefined, 'profile-profiles'); + const [profiles, setProfiles] = usePersistentInMemoryState(undefined, 'profiles'); const {bookmarkedSearches, refreshBookmarkedSearches} = useBookmarkedSearches(user?.id) const [isLoadingMore, setIsLoadingMore] = useState(false); const [isReloading, setIsReloading] = useState(false);