Pre compute compatibility scores for faster profile lookup

This commit is contained in:
MartinBraquet
2025-11-26 22:49:33 +01:00
parent f97d24402e
commit aa35fa3b2b
17 changed files with 391 additions and 259 deletions

View File

@@ -1,62 +1,29 @@
import {groupBy, sortBy} from 'lodash'
import {APIError, type APIHandler} from 'api/helpers/endpoint'
import {getCompatibilityScore, hasAnsweredQuestions} from 'common/profiles/compatibility-score'
import {getCompatibilityAnswers, getGenderCompatibleProfiles, getProfile,} from 'shared/profiles/supabase'
import {log} from 'shared/utils'
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
return getCompatibleProfiles(props.userId, false)
return getCompatibleProfiles(props.userId)
}
export const getCompatibleProfiles = async (
userId: string,
includeProfilesWithoutPromptAnswers: boolean = true,
) => {
const profile = await getProfile(userId)
log('got profile', {
id: profile?.id,
userId: profile?.user_id,
username: profile?.user?.username,
})
if (!profile) throw new APIError(404, 'Profile not found')
let profiles = await getGenderCompatibleProfiles(profile)
const profileAnswers = await getCompatibilityAnswers([
userId,
...profiles.map((l) => l.user_id),
])
log('got profile answers ' + profileAnswers.length)
const answersByUserId = groupBy(profileAnswers, 'creator_id')
if (!includeProfilesWithoutPromptAnswers) {
profiles = profiles.filter((l) => hasAnsweredQuestions(answersByUserId[l.user_id]))
if (!hasAnsweredQuestions(answersByUserId[profile.user_id])) profiles = []
}
const profileCompatibilityScores = Object.fromEntries(
profiles.map(
(l) =>
[
l.user_id,
getCompatibilityScore(
answersByUserId[profile.user_id] ?? [],
answersByUserId[l.user_id] ?? []
),
] as const
)
const pg = createSupabaseDirectClient()
const scores = await pg.map(
`select *
from compatibility_scores
where score is not null
and (user_id_1 = $1 or user_id_2 = $1)`,
[userId],
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const
)
const sortedCompatibleProfiles = sortBy(
profiles,
(l) => profileCompatibilityScores[l.user_id].score
).reverse()
const profileCompatibilityScores = Object.fromEntries(scores)
// console.log('scores', profileCompatibilityScores)
return {
status: 'success',
profile,
compatibleProfiles: sortedCompatibleProfiles,
profileCompatibilityScores,
}
}

View File

@@ -1,6 +1,7 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError} from 'common/api/utils'
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
{id}, auth) => {
@@ -27,4 +28,14 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
AND creator_id = $2`,
[id, auth.uid]
)
const continuation = async () => {
// Recompute precomputed compatibility scores for this user
await recomputeCompatibilityScoresForUser(auth.uid, pg)
}
return {
status: 'success',
continue: continuation,
}
}

View File

@@ -1,10 +1,9 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {convertRow} from 'shared/profiles/supabase'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {getCompatibleProfiles} from 'api/compatible-profiles'
import {intersection} from 'lodash'
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
import {MIN_BIO_LENGTH} from "common/constants";
import {compact} from "lodash";
export type profileQueryType = {
limit?: number | undefined,
@@ -40,7 +39,7 @@ export type profileQueryType = {
lastModificationWithin?: string | undefined,
}
const userActivityColumns = ['last_online_time']
// const userActivityColumns = ['last_online_time']
export const loadProfiles = async (props: profileQueryType) => {
@@ -84,86 +83,35 @@ export const loadProfiles = async (props: profileQueryType) => {
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
// console.debug('keywords:', keywords)
// TODO: do this in SQL for better performance. Precompute compatibility scores:
// - Have a table compatibility_scores(user_id_1, user_id_2, score) that updates whenever answers from either user change.
// - Query this table directly with "ORDER BY score DESC LIMIT {limit}".
if (orderByParam === 'compatibility_score') {
if (!compatibleWithUserId) {
console.error('Incompatible with user ID')
throw Error('Incompatible with user ID')
}
const {compatibleProfiles} = await getCompatibleProfiles(compatibleWithUserId)
let profiles = compatibleProfiles.filter(
(l) =>
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
(!genders || genders.includes(l.gender ?? '')) &&
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
(!mbti || mbti.includes(l.mbti ?? '')) &&
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
(!drinks_min || (l.drinks_per_month ?? MAX_INT) >= drinks_min) &&
(!drinks_max || (l.drinks_per_month ?? MIN_INT) <= drinks_max) &&
(!pref_relation_styles ||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
(!pref_romantic_styles ||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
(!diet ||
intersection(diet, l.diet).length) &&
(!political_beliefs ||
intersection(political_beliefs, l.political_beliefs).length) &&
(!relationship_status ||
intersection(relationship_status, l.relationship_status).length) &&
(!languages ||
intersection(languages, l.languages).length) &&
(!religion ||
intersection(religion, l.religion).length) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
!l.wants_kids_strength ||
l.wants_kids_strength == -1 ||
(wants_kids_strength >= 2
? l.wants_kids_strength >= wants_kids_strength
: l.wants_kids_strength <= wants_kids_strength)) &&
(has_kids == undefined ||
has_kids == -1 ||
(has_kids == 0 && !l.has_kids) ||
(l.has_kids && l.has_kids > 0)) &&
(is_smoker === undefined || l.is_smoker === is_smoker) &&
(!l.disabled) &&
(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)
)) &&
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
)
const count = profiles.length
const cursor = after
? profiles.findIndex((l) => l.id.toString() === after) + 1
: 0
console.debug(cursor)
if (limitParam) profiles = profiles.slice(cursor, cursor + limitParam)
return {profiles, count}
if (orderByParam === 'compatibility_score' && !compatibleWithUserId) {
console.error('Incompatible with user ID')
throw Error('Incompatible with user ID')
}
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
const tablePrefix = orderByParam === 'compatibility_score'
? 'compatibility_scores'
: orderByParam === 'last_online_time'
? 'user_activity'
: 'profiles'
const tableSelection = [
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
const afterFilter = renderSql(
select(_orderBy),
from('profiles'),
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
where('profiles.id = $(after)', {after}),
)
const tableSelection = compact([
from('profiles'),
join('users on users.id = profiles.user_id'),
leftJoin(userActivityJoin),
]
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
])
const filters = [
where('looking_for_matches = true'),
@@ -281,29 +229,27 @@ export const loadProfiles = async (props: profileQueryType) => {
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
]
let selectCols = 'profiles.*, name, username, users.data as user'
if (orderByParam === 'compatibility_score') {
selectCols += ', cs.score as compatibility_score'
} else if (orderByParam === 'last_online_time') {
selectCols += ', user_activity.last_online_time'
}
const query = renderSql(
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
select(selectCols),
...tableSelection,
...filters,
orderBy(`${tablePrefix}.${orderByParam} DESC`),
after && where(
`${tablePrefix}.${orderByParam} < (
SELECT ${tablePrefix}.${orderByParam}
FROM profiles
LEFT JOIN ${userActivityJoin}
WHERE profiles.id = $(after)
)`, {after}),
orderBy(`${_orderBy} DESC`),
after && where(`${_orderBy} < (${afterFilter})`),
limitParam && limit(limitParam),
)
// console.debug('query:', query)
console.debug('query:', query)
const profiles = await pg.map(query, [], convertRow)
console.debug('profiles:', profiles)
// console.debug('profiles:', profiles)
const countQuery = renderSql(
select(`count(*) as count`),

View File

@@ -1,6 +1,7 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
{questionId, multipleChoice, prefChoices, importance, explanation},
@@ -30,5 +31,13 @@ export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = as
],
})
return result
const continuation = async () => {
// Recompute precomputed compatibility scores for this user
await recomputeCompatibilityScoresForUser(auth.uid, pg)
}
return {
result: result,
continue: continuation,
}
}

View File

@@ -0,0 +1,99 @@
import {SupabaseDirectClient} from 'shared/supabase/init'
import {Row as RowFor} from 'common/supabase/utils'
import {getCompatibilityScore, hasAnsweredQuestions} from 'common/profiles/compatibility-score'
import {getCompatibilityAnswers, getGenderCompatibleProfiles, getProfile} from "shared/profiles/supabase"
import {groupBy} from "lodash"
import {hrtime} from "node:process"
type AnswerRow = RowFor<'compatibility_answers'>
// Canonicalize pair ordering (user_id_1 < user_id_2 lexicographically)
function canonicalPair(a: string, b: string) {
return a < b ? [a, b] as const : [b, a] as const
}
export async function recomputeCompatibilityScoresForUser(
userId: string,
pg: SupabaseDirectClient,
) {
const startTs = hrtime.bigint()
// Load all answers for the target user
const answersSelf = await pg.manyOrNone<AnswerRow>(
'select * from compatibility_answers where creator_id = $1',
[userId]
)
// If the user has no answered questions, set the score to null
if (!hasAnsweredQuestions(answersSelf)) {
await pg.none(
`update compatibility_scores
set score = null
where user_id_1 = $1
or user_id_2 = $1`,
[userId]
)
return
}
const profile = await getProfile(userId, pg)
if (!profile) throw new Error(`Profile not found for user ${userId}`)
let profiles = await getGenderCompatibleProfiles(profile)
const otherUserIds = profiles.map((l) => l.user_id)
const profileAnswers = await getCompatibilityAnswers([userId, ...otherUserIds])
const answersByUser = groupBy(profileAnswers, 'creator_id')
console.log(`Recomputing compatibility scores for user ${userId}, ${otherUserIds.length} other users.`)
const rows = []
for (const otherId of otherUserIds) {
const answersOther = answersByUser[otherId] ?? []
if (!hasAnsweredQuestions(answersOther)) continue
const {score} = getCompatibilityScore(answersSelf, answersOther)
const adaptedScore = score + (Math.random() - 0.5) * 0.001 // Add some noise to avoid ties (for profile sorting / pagination)
const [u1, u2] = canonicalPair(userId, otherId)
rows.push([u1, u2, adaptedScore])
}
if (rows.length === 0) return
const values = rows
.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`)
.join(", ");
const flatParams = rows.flat();
// Upsert scores for each pair
await pg.none(
`
INSERT INTO compatibility_scores (user_id_1, user_id_2, score)
VALUES
${values}
ON CONFLICT (user_id_1, user_id_2)
DO UPDATE SET score = EXCLUDED.score
`,
flatParams
);
//
// for (const otherId of otherUserIds) {
// const answersOther = answersByUser[otherId] ?? []
// if (!hasAnsweredQuestions(answersOther)) continue
//
// const {score} = getCompatibilityScore(answersSelf, answersOther)
// const [u1, u2] = canonicalPair(userId, otherId)
// await pg.none(
// `insert into compatibility_scores (user_id_1, user_id_2, score)
// values ($1, $2, $3)
// on conflict (user_id_1, user_id_2)
// do update set score = excluded.score`,
// [u1, u2, adaptedScore]
// )
// }
const dt = Number(hrtime.bigint() - startTs) / 1e9
console.log(`Done recomputing compatibility scores for user ${userId} (${dt.toFixed(1)}s).`)
}

View File

@@ -8,9 +8,10 @@ import { getProfile } from 'shared/profiles/supabase'
export const createProfileLikeNotification = async (like: Row<'profile_likes'>) => {
const { creator_id, target_id, like_id } = like
const pg = createSupabaseDirectClient()
const targetPrivateUser = await getPrivateUser(target_id)
const profile = await getProfile(creator_id)
const profile = await getProfile(creator_id, pg)
if (!targetPrivateUser || !profile) return
@@ -35,7 +36,6 @@ export const createProfileLikeNotification = async (like: Row<'profile_likes'>)
sourceUserAvatarUrl: profile.pinned_url ?? profile.user.avatarUrl,
sourceText: '',
}
const pg = createSupabaseDirectClient()
return await insertNotificationToSupabase(notification, pg)
}
@@ -48,7 +48,8 @@ export const createProfileShipNotification = async (
const creator = await getUser(creator_id)
const targetPrivateUser = await getPrivateUser(recipientId)
const profile = await getProfile(otherTargetId)
const pg = createSupabaseDirectClient()
const profile = await getProfile(otherTargetId, pg)
if (!creator || !targetPrivateUser || !profile) {
console.error('Could not load user object', {
@@ -86,6 +87,5 @@ export const createProfileShipNotification = async (
otherTargetId,
},
}
const pg = createSupabaseDirectClient()
return await insertNotificationToSupabase(notification, pg)
}

View File

@@ -1,8 +1,8 @@
import { areGenderCompatible } from 'common/profiles/compatibility-util'
import { type Profile, type ProfileRow } from 'common/profiles/profile'
import { type User } from 'common/user'
import { Row } from 'common/supabase/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import {areGenderCompatible} from 'common/profiles/compatibility-util'
import {type Profile, type ProfileRow} from 'common/profiles/profile'
import {type User} from 'common/user'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
export type ProfileAndUserRow = ProfileRow & {
name: string
@@ -17,7 +17,7 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
// Remove internal/search-only fields from the returned profile row
const profile: any = {
...row,
user: { ...row.user, name: row.name, username: row.username } as User,
user: {...row.user, name: row.name, username: row.username} as User,
}
delete profile.bio_text
delete profile.bio_tsv
@@ -26,18 +26,15 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
const PROFILE_COLS = 'profiles.*, name, username, users.data as user'
export const getProfile = async (userId: string) => {
const pg = createSupabaseDirectClient()
export const getProfile = async (userId: string, client?: SupabaseDirectClient) => {
const pg = client ?? createSupabaseDirectClient()
return await pg.oneOrNone(
`
select
${PROFILE_COLS}
from
profiles
join
users on users.id = profiles.user_id
where
user_id = $1
select ${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
where user_id = $1
`,
[userId],
convertRow
@@ -48,14 +45,11 @@ export const getProfiles = async (userIds: string[]) => {
const pg = createSupabaseDirectClient()
return await pg.map(
`
select
${PROFILE_COLS}
from
profiles
join
users on users.id = profiles.user_id
where
user_id = any($1)
select ${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
where user_id = any ($1)
`,
[userIds],
convertRow
@@ -66,19 +60,17 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
const pg = createSupabaseDirectClient()
const profiles = await pg.map(
`
select
${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
where
user_id != $(user_id)
and looking_for_matches
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and (data->>'userDeleted' != 'true' or data->>'userDeleted' is null)
and profiles.pinned_url is not null
`,
{ ...profile },
select ${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
where user_id != $(user_id)
and looking_for_matches
and (data ->> 'isBannedFromPosting' != 'true' or data ->> 'isBannedFromPosting' is null)
and (data ->> 'userDeleted' != 'true' or data ->> 'userDeleted' is null)
and profiles.pinned_url is not null
`,
{...profile},
convertRow
)
return profiles.filter((l: Profile) => areGenderCompatible(profile, l))
@@ -91,31 +83,30 @@ export const getCompatibleProfiles = async (
const pg = createSupabaseDirectClient()
return await pg.map(
`
select
${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
where
user_id != $(user_id)
and looking_for_matches
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and (data->>'userDeleted' != 'true' or data->>'userDeleted' is null)
select ${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
where user_id != $(user_id)
and looking_for_matches
and (data ->> 'isBannedFromPosting' != 'true' or data ->> 'isBannedFromPosting' is null)
and (data ->> 'userDeleted' != 'true' or data ->> 'userDeleted' is null)
-- Gender
and (profiles.gender = any($(pref_gender)) or profiles.gender = 'non-binary')
and ($(gender) = any(profiles.pref_gender) or $(gender) = 'non-binary')
-- Gender
and (profiles.gender = any ($(pref_gender)) or profiles.gender = 'non-binary')
and ($(gender) = any (profiles.pref_gender) or $(gender) = 'non-binary')
-- Age
and profiles.age >= $(pref_age_min)
and profiles.age <= $(pref_age_max)
and $(age) >= profiles.pref_age_min
and $(age) <= profiles.pref_age_max
-- Age
and profiles.age >= $(pref_age_min)
and profiles.age <= $(pref_age_max)
and $(age) >= profiles.pref_age_min
and $(age) <= profiles.pref_age_max
-- Location
and calculate_earth_distance_km($(city_latitude), $(city_longitude), profiles.city_latitude, profiles.city_longitude) < $(radiusKm)
`,
{ ...profile, radiusKm: radiusKm ?? 40_000 },
-- Location
and calculate_earth_distance_km($(city_latitude), $(city_longitude), profiles.city_latitude,
profiles.city_longitude) < $(radiusKm)
`,
{...profile, radiusKm: radiusKm ?? 40_000},
convertRow
)
}
@@ -124,8 +115,9 @@ export const getCompatibilityAnswers = async (userIds: string[]) => {
const pg = createSupabaseDirectClient()
return await pg.manyOrNone<Row<'compatibility_answers'>>(
`
select * from compatibility_answers
where creator_id = any($1)
select *
from compatibility_answers
where creator_id = any ($1)
`,
[userIds]
)

View File

@@ -77,6 +77,7 @@ export function createSupabaseDirectClient(
password?: string
) {
if (pgpDirect) return pgpDirect
console.log('Creating Supabase direct client')
instanceId = instanceId ?? getInstanceId()
if (!instanceId) {
throw new Error(

View File

@@ -61,8 +61,9 @@ export function from(clause: string, formatValues?: any) {
return buildSql({ from })
}
export function join(clause: string) {
return buildSql({ join: clause })
export function join(clause: string, formatValues?: any) {
const join = pgp.as.format(clause, formatValues)
return buildSql({ join })
}
export function leftJoin(clause: string, formatValues?: any) {

View File

@@ -0,0 +1,57 @@
-- Precomputed compatibility scores between pairs of users
CREATE TABLE IF NOT EXISTS compatibility_scores (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
modified_time TIMESTAMPTZ DEFAULT now() NOT NULL,
user_id_1 TEXT NOT NULL, --- lowest-id user
user_id_2 TEXT NOT NULL, --- highest-id user
-- geometric mean score in range [0,1]
score DOUBLE PRECISION
);
-- Ensure canonical ordering and uniqueness of pairs
CREATE UNIQUE INDEX IF NOT EXISTS compatibility_scores_user_pair_unique
ON public.compatibility_scores (user_id_1, user_id_2);
-- Foreign keys
ALTER TABLE compatibility_scores
ADD CONSTRAINT compatibility_scores_user_id_1_fkey
FOREIGN KEY (user_id_1)
REFERENCES users(id)
ON DELETE CASCADE;
ALTER TABLE compatibility_scores
ADD CONSTRAINT compatibility_scores_user_id_2_fkey
FOREIGN KEY (user_id_2)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE compatibility_scores ENABLE ROW LEVEL SECURITY;
-- Public read policy (scores are not sensitive by themselves)
DROP POLICY IF EXISTS "public read" ON compatibility_scores;
CREATE POLICY "public read" ON compatibility_scores
FOR SELECT USING (true);
-- Update modified_time on any row update
CREATE OR REPLACE FUNCTION set_modified_time_compat_scores()
RETURNS TRIGGER AS $$
BEGIN
NEW.modified_time = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS set_modified_time_compat_scores_trigger ON compatibility_scores;
CREATE TRIGGER set_modified_time_compat_scores_trigger
BEFORE UPDATE ON compatibility_scores
FOR EACH ROW EXECUTE FUNCTION set_modified_time_compat_scores();
-- Helpful indexes
CREATE INDEX IF NOT EXISTS compatibility_scores_score_desc_idx
ON public.compatibility_scores (score DESC);
CREATE INDEX IF NOT EXISTS compatibility_scores_user1_idx
ON public.compatibility_scores (user_id_1);
CREATE INDEX IF NOT EXISTS compatibility_scores_user2_idx
ON public.compatibility_scores (user_id_2);

View File

@@ -11,6 +11,7 @@ BEGIN;
\i backend/supabase/compatibility_answers_free.sql
\i backend/supabase/profile_comments.sql
\i backend/supabase/compatibility_answers.sql
\i backend/supabase/compatibility_scores.sql
\i backend/supabase/profile_likes.sql
\i backend/supabase/compatibility_prompts.sql
\i backend/supabase/profile_ships.sql

View File

@@ -273,8 +273,8 @@ export const API = (_apiTypeCheck = {
rateLimited: true,
props: z.object({userId: z.string()}),
returns: {} as {
profile: Profile
compatibleProfiles: Profile[]
// profile: Profile
// compatibleProfiles: Profile[]
profileCompatibilityScores: {
[userId: string]: CompatibilityScore
}
@@ -358,6 +358,9 @@ export const API = (_apiTypeCheck = {
props: z.object({
id: z.number(),
}),
returns: {} as {
status: 'success'
},
summary: 'Delete a compatibility answer',
tag: 'Compatibility',
},

View File

@@ -1,6 +1,6 @@
import { keyBy, sumBy } from 'lodash'
import { ProfileRow } from 'common/profiles/profile'
import { Row as rowFor } from 'common/supabase/utils'
import {keyBy, sumBy} from 'lodash'
import {ProfileRow} from 'common/profiles/profile'
import {Row as rowFor} from 'common/supabase/utils'
import {
areAgeCompatible,
areLocationCompatible,
@@ -24,15 +24,8 @@ export const getCompatibilityScore = (
answers1: rowFor<'compatibility_answers'>[],
answers2: rowFor<'compatibility_answers'>[]
): CompatibilityScore => {
const {
score: score1,
maxScore: maxScore1,
answerCount,
} = getAnswersCompatibility(answers1, answers2)
const { score: score2, maxScore: maxScore2 } = getAnswersCompatibility(
answers2,
answers1
)
const {score: score1, maxScore: maxScore1, answerCount} = getAnswersCompatibility(answers1, answers2)
const {score: score2, maxScore: maxScore2} = getAnswersCompatibility(answers2, answers1)
// >=100 answers in common leads to no weight toward 50%.
// Use sqrt for diminishing returns to answering more questions.
@@ -49,7 +42,7 @@ export const getCompatibilityScore = (
const confidence =
answerCount < 10 ? 'low' : answerCount < 100 ? 'medium' : 'high'
return { score: geometricMean, confidence }
return {score: geometricMean, confidence}
}
const getAnswersCompatibility = (
@@ -73,7 +66,7 @@ const getAnswersCompatibility = (
return getAnswerCompatibilityImportanceScore(a, answer2)
})
return { score, maxScore, answerCount }
return {score, maxScore, answerCount}
}
export function getAnswerCompatibilityImportanceScore(

View File

@@ -134,6 +134,7 @@ export type Database = {
compatibility_prompts: {
Row: {
answer_type: string
category: string | null
created_time: string
creator_id: string | null
id: number
@@ -143,6 +144,7 @@ export type Database = {
}
Insert: {
answer_type?: string
category?: string | null
created_time?: string
creator_id?: string | null
id?: number
@@ -152,6 +154,7 @@ export type Database = {
}
Update: {
answer_type?: string
category?: string | null
created_time?: string
creator_id?: string | null
id?: number
@@ -169,6 +172,48 @@ export type Database = {
},
]
}
compatibility_scores: {
Row: {
created_time: string
id: number
modified_time: string
score: number | null
user_id_1: string
user_id_2: string
}
Insert: {
created_time?: string
id?: never
modified_time?: string
score?: number | null
user_id_1: string
user_id_2: string
}
Update: {
created_time?: string
id?: never
modified_time?: string
score?: number | null
user_id_1?: string
user_id_2?: string
}
Relationships: [
{
foreignKeyName: 'compatibility_scores_user_id_1_fkey'
columns: ['user_id_1']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
{
foreignKeyName: 'compatibility_scores_user_id_2_fkey'
columns: ['user_id_2']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
]
}
contact: {
Row: {
content: Json | null

View File

@@ -43,6 +43,8 @@ import {useIsLooking} from 'web/hooks/use-is-looking'
import {DropdownButton} from '../filters/desktop-filters'
import {buildArray} from 'common/util/array'
import toast from "react-hot-toast";
import {useCompatibleProfiles} from "web/hooks/use-profiles";
import {CompatibleBadge} from "web/components/widgets/compatible-badge";
const NUM_QUESTIONS_TO_SHOW = 8
@@ -83,6 +85,10 @@ export function CompatibilityQuestionsDisplay(props: {
}) {
const {isCurrentUser, user, fromSignup, fromProfilePage, profile} = props
const currentUser = useUser()
const compatibleProfiles = useCompatibleProfiles(currentUser?.id)
const compatibilityScore = compatibleProfiles?.profileCompatibilityScores?.[profile.user_id]
const {refreshCompatibilityQuestions, compatibilityQuestions} = useCompatibilityQuestionsWithAnswerCount()
const {refreshCompatibilityAnswers, compatibilityAnswers} = useUserCompatibilityAnswers(user.id)
@@ -101,10 +107,10 @@ export function CompatibilityQuestionsDisplay(props: {
)
const {skippedQuestions, answeredQuestions, otherQuestions} = separateQuestionsArray(
compatibilityQuestions,
skippedAnswerQuestionIds,
answeredQuestionIds
)
compatibilityQuestions,
skippedAnswerQuestionIds,
answeredQuestionIds
)
const refreshCompatibilityAll = () => {
refreshCompatibilityAnswers()
@@ -117,7 +123,6 @@ export function CompatibilityQuestionsDisplay(props: {
`compatibility-sort-${user.id}`
)
const currentUser = useUser()
const comparedUserId = fromProfilePage?.user_id ?? currentUser?.id
const {compatibilityAnswers: comparedAnswers} = useUserCompatibilityAnswers(comparedUserId)
const questionIdToComparedAnswer = keyBy(comparedAnswers, 'question_id')
@@ -169,12 +174,17 @@ export function CompatibilityQuestionsDisplay(props: {
return (
<Col className="gap-4">
<Row className="flex-wrap items-center justify-between gap-x-6 gap-y-4">
<Subtitle>{`${
isCurrentUser ? 'Your' : shortenName(user.name) + `'s`
} Compatibility Prompts`}</Subtitle>
<Row className={'gap-8'}>
<Subtitle>{`${
isCurrentUser ? 'Your' : shortenName(user.name) + `'s`
} Compatibility Prompts`}</Subtitle>
{compatibilityScore &&
<CompatibleBadge compatibility={compatibilityScore} className={'mt-4 mr-4'}/>
}
</Row>
{(!isCurrentUser || fromProfilePage) && (
<CompatibilitySortWidget
className="text-sm sm:flex"
className="text-sm sm:flex mt-4"
sort={sort}
setSort={setSort}
user={user}
@@ -408,8 +418,11 @@ export function CompatibilityAnswerBlock(props: {
onClick: () => {
deleteCompatibilityAnswer(answer.id, user.id)
.then(() => refreshCompatibilityAll())
.catch((e) => {toast.error(e.message)})
.finally(() => {})
.catch((e) => {
toast.error(e.message)
})
.finally(() => {
})
},
},
]}
@@ -427,9 +440,14 @@ export function CompatibilityAnswerBlock(props: {
icon: <TrashIcon className="h-5 w-5"/>,
onClick: () => {
submitCompatibilityAnswer(getEmptyAnswer(user.id, question.id))
.then(() => {refreshCompatibilityAll()})
.catch((e) => {toast.error(e.message)})
.finally(() => {})
.then(() => {
refreshCompatibilityAll()
})
.catch((e) => {
toast.error(e.message)
})
.finally(() => {
})
},
},
]}

View File

@@ -63,7 +63,7 @@ export function ProfileInfo(props: {
const isProfileVisible = currentUser || profile.visibility === 'public'
const { data: userActivity } = useUserActivity(user?.id)
const {data: userActivity} = useUserActivity(user?.id)
return (
<>

View File

@@ -1,24 +1,18 @@
import { sortBy } from 'lodash'
import { useEffect } from 'react'
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
import { api } from 'web/lib/api'
import { APIResponse } from 'common/api/schema'
import { useProfileByUserId } from './use-profile'
import { getProfilesCompatibilityFactor } from 'common/profiles/compatibility-score'
import {useEffect} from 'react'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {api} from 'web/lib/api'
import {APIResponse} from 'common/api/schema'
export const useCompatibleProfiles = (
userId: string | null | undefined,
options?: { sortWithModifiers?: boolean }
) => {
const [data, setData] = usePersistentInMemoryState<
APIResponse<'compatible-profiles'> | undefined | null
>(undefined, `compatible-profiles-${userId}`)
const profile = useProfileByUserId(userId ?? undefined)
useEffect(() => {
if (userId) {
api('compatible-profiles', { userId })
api('compatible-profiles', {userId})
.then(setData)
.catch((e) => {
if (e.code === 404) {
@@ -27,15 +21,10 @@ export const useCompatibleProfiles = (
throw e
}
})
} else if (userId === null) setData(null)
} else if (userId === null) {
setData(null)
}
}, [userId])
if (data && profile && options?.sortWithModifiers) {
data.compatibleProfiles = sortBy(data.compatibleProfiles, (l) => {
const modifier = !profile ? 1 : getProfilesCompatibilityFactor(profile, l)
return -1 * modifier * data.profileCompatibilityScores[l.user.id].score
})
}
return data
}