Store option IDs instead of EN labels in profiles and make keyword search match selected options

This commit is contained in:
MartinBraquet
2026-01-26 22:53:31 +01:00
parent ffc966f3b3
commit 542152eadb
28 changed files with 462 additions and 219 deletions

View File

@@ -8,8 +8,8 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 24
versionName "1.2.0"
versionCode 25
versionName "1.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@compass/api",
"description": "Backend API endpoints",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"scripts": {
"watch:serve": "tsx watch src/serve.ts",

View File

@@ -38,6 +38,7 @@ export type profileQueryType = {
skipId?: string | undefined,
orderBy?: string | undefined,
lastModificationWithin?: string | undefined,
locale?: string | undefined,
} & {
[K in OptionTableKey]?: string[] | undefined
}
@@ -82,6 +83,7 @@ export const loadProfiles = async (props: profileQueryType) => {
orderBy: orderByParam = 'created_time',
lastModificationWithin,
skipId,
locale = 'en',
} = props
const filterLocation = lat && lon && radius
@@ -102,6 +104,10 @@ export const loadProfiles = async (props: profileQueryType) => {
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
const joinInterests = !!interests?.length
const joinCauses = !!causes?.length
const joinWork = !!work?.length
// Pre-aggregated interests per profile
function getManyToManyJoin(label: OptionTableKey) {
return `(
@@ -122,9 +128,9 @@ export const loadProfiles = async (props: profileQueryType) => {
const joins = [
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
interests && leftJoin(interestsJoin),
causes && leftJoin(causesJoin),
work && leftJoin(workJoin),
joinInterests && leftJoin(interestsJoin),
joinCauses && leftJoin(causesJoin),
joinWork && leftJoin(workJoin),
]
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
@@ -146,10 +152,23 @@ export const loadProfiles = async (props: profileQueryType) => {
SELECT 1 FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
WHERE profile_${label}.profile_id = profiles.id
AND ${label}.name = ANY (ARRAY[$(values)])
AND ${label}.id = ANY (ARRAY[$(values)])
)`
}
function getOptionClauseKeyword(label: OptionTableKey) {
return `EXISTS (
SELECT 1 FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
LEFT JOIN ${label}_translations
ON ${label}_translations.option_id = profile_${label}.option_id
AND ${label}_translations.locale = $(locale)
WHERE profile_${label}.profile_id = profiles.id
AND lower(COALESCE(${label}_translations.name, ${label}.name)) ILIKE '%' || lower($(word)) || '%'
)`
}
const filters = [
where('looking_for_matches = true'),
where(`profiles.disabled != true`),
@@ -160,8 +179,14 @@ export const loadProfiles = async (props: profileQueryType) => {
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
...keywords.map(word => where(
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
{word}
`lower(users.name) ilike '%' || lower($(word)) || '%'
or lower(search_text) ilike '%' || lower($(word)) || '%'
or search_tsv @@ phraseto_tsquery('english', $(word))
OR ${getOptionClauseKeyword('interests')}
OR ${getOptionClauseKeyword('causes')}
OR ${getOptionClauseKeyword('work')}
`,
{word, locale}
)),
genders?.length && where(`gender = ANY($(genders))`, {genders}),
@@ -227,7 +252,7 @@ export const loadProfiles = async (props: profileQueryType) => {
{religion}
),
interests?.length && where(getManyToManyClause('interests'), {values: interests}),
interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}),
causes?.length && where(getManyToManyClause('causes'), {values: causes}),
@@ -278,9 +303,9 @@ export const loadProfiles = async (props: profileQueryType) => {
} else if (orderByParam === 'last_online_time') {
selectCols += ', user_activity.last_online_time'
}
if (interests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
if (causes) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
if (work) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
if (joinCauses) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
if (joinWork) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
const query = renderSql(
select(selectCols),

View File

@@ -5,15 +5,22 @@ import {tryCatch} from 'common/util/try-catch'
import {OPTION_TABLES} from "common/profiles/constants";
export const updateOptions: APIHandler<'update-options'> = async (
{table, names},
{table, values},
auth
) => {
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
if (!names || !Array.isArray(names) || names.length === 0) {
throw new APIError(400, 'No names provided')
if (!values || !Array.isArray(values)) {
throw new APIError(400, 'No ids provided')
}
log('Updating profile options', {table, names})
const idsWithNumbers = values.map(id => {
const numberId = Number(id)
return isNaN(numberId) ? {isNumber: false, v: id} : {isNumber: true, v: numberId}
})
const names: string[] = idsWithNumbers.filter(item => !item.isNumber).map(item => item.v) as string[]
const ids: number[] = idsWithNumbers.filter(item => item.isNumber).map(item => item.v) as number[]
log('Updating profile options', {table, ids, names})
const pg = createSupabaseDirectClient()
@@ -25,8 +32,20 @@ export const updateOptions: APIHandler<'update-options'> = async (
const profileId = profileIdResult.id
const result = await tryCatch(pg.tx(async (t) => {
const ids: number[] = []
for (const name of names) {
const currentOptionsResult = await t.manyOrNone<{ id: string }>(
`SELECT option_id as id
FROM profile_${table}
WHERE profile_id = $1`,
[profileId]
)
const currentOptions = currentOptionsResult.map(row => row.id)
if (currentOptions.sort().join(',') === ids.sort().join(',') && !names?.length) {
log(`Skipping /update-${table} because they are already the same`)
return undefined
}
// Add new options
for (const name of (names || [])) {
const row = await t.one<{ id: number }>(
`INSERT INTO ${table} (name, creator_id)
VALUES ($1, $2)

View File

@@ -110,6 +110,8 @@ export const sinclairProfile: ProfileRow = {
},
bio_text: 'the futa in futarchy',
bio_tsv: 'the futa in futarchy',
search_text: 'the futa in futarchy',
search_tsv: 'the futa in futarchy',
age: 25,
}
@@ -221,5 +223,7 @@ export const jamesProfile: ProfileRow = {
},
bio_text: 'the futa in futarchy',
bio_tsv: 'the futa in futarchy',
search_text: 'the futa in futarchy',
search_tsv: 'the futa in futarchy',
age: 32,
}

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS causes_translations
(
option_id BIGINT NOT NULL REFERENCES causes (id) ON DELETE CASCADE,
locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc.
name TEXT NOT NULL,
PRIMARY KEY (option_id, locale)
);
-- Row Level Security
ALTER TABLE causes_translations
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON causes_translations;
CREATE POLICY "public read" ON causes_translations
FOR SELECT USING (true);
CREATE INDEX idx_causes_translations_option_locale
ON causes_translations (option_id, locale);
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_causes_translations_name_trgm
ON causes_translations
USING GIN (name gin_trgm_ops);

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS interests_translations
(
option_id BIGINT NOT NULL REFERENCES interests (id) ON DELETE CASCADE,
locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc.
name TEXT NOT NULL,
PRIMARY KEY (option_id, locale)
);
-- Row Level Security
ALTER TABLE interests_translations
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON interests_translations;
CREATE POLICY "public read" ON interests_translations
FOR SELECT USING (true);
DROP INDEX IF EXISTS idx_interests_translations_option_locale;
CREATE INDEX idx_interests_translations_option_locale
ON interests_translations (option_id, locale);
CREATE EXTENSION IF NOT EXISTS pg_trgm;
DROP INDEX IF EXISTS idx_interests_translations_name_trgm;
CREATE INDEX idx_interests_translations_name_trgm
ON interests_translations
USING GIN (name gin_trgm_ops);

View File

@@ -1,4 +1,5 @@
BEGIN;
\i backend/supabase/rebuild_profile_search.sql
\i backend/supabase/functions.sql
\i backend/supabase/firebase.sql
\i backend/supabase/profiles.sql

View File

@@ -21,3 +21,27 @@ CREATE INDEX idx_profile_causes_profile
CREATE INDEX idx_profile_causes_interest
ON profile_causes (option_id);
-- Trigger to update /get-profiles search
CREATE OR REPLACE FUNCTION trg_profile_causes_rebuild_search()
RETURNS trigger AS
$$
BEGIN
PERFORM rebuild_profile_search(
COALESCE(NEW.profile_id, OLD.profile_id)
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_profile_causes_search_ins
AFTER INSERT
ON profile_causes
FOR EACH ROW
EXECUTE FUNCTION trg_profile_causes_rebuild_search();
CREATE TRIGGER trg_profile_causes_search_del
AFTER DELETE
ON profile_causes
FOR EACH ROW
EXECUTE FUNCTION trg_profile_causes_rebuild_search();

View File

@@ -21,3 +21,28 @@ CREATE INDEX idx_profile_interests_profile
CREATE INDEX idx_profile_interests_interest
ON profile_interests (option_id);
-- Trigger to update /get-profiles search
CREATE OR REPLACE FUNCTION trg_profile_interests_rebuild_search()
RETURNS trigger AS
$$
BEGIN
PERFORM rebuild_profile_search(
COALESCE(NEW.profile_id, OLD.profile_id)
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_profile_interests_search_ins
AFTER INSERT
ON profile_interests
FOR EACH ROW
EXECUTE FUNCTION trg_profile_interests_rebuild_search();
CREATE TRIGGER trg_profile_interests_search_del
AFTER DELETE
ON profile_interests
FOR EACH ROW
EXECUTE FUNCTION trg_profile_interests_rebuild_search();

View File

@@ -21,3 +21,27 @@ CREATE INDEX idx_profile_work_profile
CREATE INDEX idx_profile_work_interest
ON profile_work (option_id);
-- Trigger to update /get-profiles search
CREATE OR REPLACE FUNCTION trg_profile_work_rebuild_search()
RETURNS trigger AS
$$
BEGIN
PERFORM rebuild_profile_search(
COALESCE(NEW.profile_id, OLD.profile_id)
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_profile_work_search_ins
AFTER INSERT
ON profile_work
FOR EACH ROW
EXECUTE FUNCTION trg_profile_work_rebuild_search();
CREATE TRIGGER trg_profile_work_search_del
AFTER DELETE
ON profile_work
FOR EACH ROW
EXECUTE FUNCTION trg_profile_work_rebuild_search();

View File

@@ -123,28 +123,35 @@ CREATE INDEX profiles_bio_trgm_idx
--- bio_text
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
UPDATE profiles
SET bio_text = (
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
FROM jsonb_path_query(bio, '$.**.text') AS t(value)
);
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
CREATE OR REPLACE FUNCTION update_bio_text()
ALTER TABLE profiles
ADD COLUMN search_text TEXT,
ADD COLUMN search_tsv tsvector;
-- Rebuild search (search_txt and search_tsv)
CREATE OR REPLACE FUNCTION trg_profiles_rebuild_search()
RETURNS trigger AS $$
BEGIN
NEW.bio_text := (
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value)
);
PERFORM rebuild_profile_search(NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_bio_text
BEFORE INSERT OR UPDATE OF bio ON profiles
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
DROP FUNCTION IF EXISTS update_bio_text;
DROP TRIGGER IF EXISTS trg_update_bio_text ON profiles;
CREATE TRIGGER trg_profiles_rebuild_search
AFTER INSERT OR UPDATE OF bio
ON profiles
FOR EACH ROW
EXECUTE FUNCTION trg_profiles_rebuild_search();
CREATE INDEX profiles_search_tsv_idx
ON profiles USING GIN (search_tsv);

View File

@@ -0,0 +1,44 @@
CREATE OR REPLACE FUNCTION rebuild_profile_search(profile_id_param BIGINT)
RETURNS void AS
$$
DECLARE
bio_part TEXT;
interests_part TEXT;
causes_part TEXT;
work_part TEXT;
BEGIN
-- Bio text
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
INTO bio_part
FROM profiles p,
jsonb_path_query(p.bio, '$.**.text') AS t(value)
WHERE p.id = profile_id_param;
-- Interests text
SELECT string_agg(i.name, ' ')
INTO interests_part
FROM profile_interests pi
JOIN interests i ON i.id = pi.option_id
WHERE pi.profile_id = profile_id_param;
-- Causes text
SELECT string_agg(i.name, ' ')
INTO causes_part
FROM profile_causes pc
JOIN causes i ON i.id = pc.option_id
WHERE pc.profile_id = profile_id_param;
-- Work text
SELECT string_agg(i.name, ' ')
INTO work_part
FROM profile_work pw
JOIN work i ON i.id = pw.option_id
WHERE pw.profile_id = profile_id_param;
UPDATE profiles
SET bio_text = bio_part,
search_text = concat_ws(' ', bio_part, interests_part, causes_part, work_part),
search_tsv = to_tsvector('english', concat_ws(' ', bio_part, interests_part, causes_part, work_part))
WHERE id = profile_id_param;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS work_translations
(
option_id BIGINT NOT NULL REFERENCES work (id) ON DELETE CASCADE,
locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc.
name TEXT NOT NULL,
PRIMARY KEY (option_id, locale)
);
-- Row Level Security
ALTER TABLE work_translations
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON work_translations;
CREATE POLICY "public read" ON work_translations
FOR SELECT USING (true);
CREATE INDEX idx_work_translations_option_locale
ON work_translations (option_id, locale);
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_work_translations_name_trgm
ON work_translations
USING GIN (name gin_trgm_ops);

View File

@@ -482,6 +482,7 @@ export const API = (_apiTypeCheck = {
radius: z.coerce.number().optional(),
compatibleWithUserId: z.string().optional(),
skipId: z.string().optional(),
locale: z.string().optional(),
orderBy: z
.enum(['last_online_time', 'created_time', 'compatibility_score'])
.optional()
@@ -504,6 +505,7 @@ export const API = (_apiTypeCheck = {
props: z
.object({
table: z.enum(OPTION_TABLES),
locale: z.string().optional(),
})
.strict(),
summary: 'Get profile options like interests',
@@ -517,7 +519,7 @@ export const API = (_apiTypeCheck = {
props: z
.object({
table: z.enum(OPTION_TABLES),
names: arraybeSchema.optional(),
values: arraybeSchema.optional(),
})
.strict(),
summary: 'Update profile options like interests',

View File

@@ -21,30 +21,30 @@ export const getProfileRow = async (userId: string, db: SupabaseClient): Promise
const interestsRes = await run(
db
.from('profile_interests')
.select('interests(name)')
.select('interests(name, id)')
.eq('profile_id', profile.id)
)
const interests = interestsRes.data?.map((row: any) => row.interests.name) || []
const interests = interestsRes.data?.map((row: any) => String(row.interests.id)) || []
// Fetch causes
const causesRes = await run(
db
.from('profile_causes')
.select('causes(name)')
.select('causes(name, id)')
.eq('profile_id', profile.id)
)
const causes = causesRes.data?.map((row: any) => row.causes.name) || []
const causes = causesRes.data?.map((row: any) => String(row.causes.id)) || []
// Fetch causes
const workRes = await run(
db
.from('profile_work')
.select('work(name)')
.select('work(name, id)')
.eq('profile_id', profile.id)
)
const work = workRes.data?.map((row: any) => row.work.name) || []
const work = workRes.data?.map((row: any) => String(row.work.id)) || []
// console.debug('work', work)
// console.debug({work, interests, causes})
return {
...profile,

View File

@@ -78,6 +78,32 @@ export type Database = {
},
]
}
causes_translations: {
Row: {
locale: string
name: string
option_id: number
}
Insert: {
locale: string
name: string
option_id: number
}
Update: {
locale?: string
name?: string
option_id?: number
}
Relationships: [
{
foreignKeyName: 'causes_translations_option_id_fkey'
columns: ['option_id']
isOneToOne: false
referencedRelation: 'causes'
referencedColumns: ['id']
},
]
}
compatibility_answers: {
Row: {
created_time: string
@@ -327,6 +353,32 @@ export type Database = {
},
]
}
interests_translations: {
Row: {
locale: string
name: string
option_id: number
}
Insert: {
locale: string
name: string
option_id: number
}
Update: {
locale?: string
name?: string
option_id?: number
}
Relationships: [
{
foreignKeyName: 'interests_translations_option_id_fkey'
columns: ['option_id']
isOneToOne: false
referencedRelation: 'interests'
referencedColumns: ['id']
},
]
}
private_user_message_channel_members: {
Row: {
channel_id: number
@@ -814,6 +866,8 @@ export type Database = {
religion: string[] | null
religious_belief_strength: number | null
religious_beliefs: string | null
search_text: string | null
search_tsv: unknown
twitter: string | null
university: string | null
user_id: string
@@ -869,6 +923,8 @@ export type Database = {
religion?: string[] | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
search_text?: string | null
search_tsv?: unknown
twitter?: string | null
university?: string | null
user_id: string
@@ -924,6 +980,8 @@ export type Database = {
religion?: string[] | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
search_text?: string | null
search_tsv?: unknown
twitter?: string | null
university?: string | null
user_id?: string
@@ -1289,6 +1347,32 @@ export type Database = {
},
]
}
work_translations: {
Row: {
locale: string
name: string
option_id: number
}
Insert: {
locale: string
name: string
option_id: number
}
Update: {
locale?: string
name?: string
option_id?: number
}
Relationships: [
{
foreignKeyName: 'work_translations_option_id_fkey'
columns: ['option_id']
isOneToOne: false
referencedRelation: 'work'
referencedColumns: ['id']
},
]
}
}
Views: {
[_ in never]: never
@@ -1341,6 +1425,10 @@ export type Database = {
}
millis_to_ts: { Args: { millis: number }; Returns: string }
random_alphanumeric: { Args: { length: number }; Returns: string }
rebuild_profile_search: {
Args: { profile_id_param: number }
Returns: undefined
}
show_limit: { Args: never; Returns: number }
show_trgm: { Args: { '': string }; Returns: string[] }
to_jsonb: { Args: { '': Json }; Returns: Json }

View File

@@ -4,6 +4,8 @@ import {Col} from "web/components/layout/col";
import clsx from "clsx";
import {colClassName, labelClassName} from "web/pages/signup";
import {MultiCheckbox} from "web/components/multi-checkbox";
import {invert} from "lodash";
import {useLocale} from "web/lib/locale";
export function AddOptionEntry(props: {
title?: string
@@ -14,12 +16,17 @@ export function AddOptionEntry(props: {
label: OptionTableKey,
}) {
const {profile, setProfile, label, choices, setChoices, title} = props
const {locale} = useLocale()
const sortedChoices = Object.fromEntries(
Object.entries(invert(choices)).sort((a, b) =>
a[0].localeCompare(b[0], locale)
)
)
return <Col className={clsx(colClassName)}>
{title && <label className={clsx(labelClassName)}>{title}</label>}
<MultiCheckbox
choices={choices}
selected={profile[label] ?? []}
translationPrefix={`profile.${label}`}
choices={sortedChoices}
selected={(profile[label] ?? []).map((s) => String(s))}
onChange={(selected) => setProfile(label, selected)}
addOption={(v: string) => {
console.log(`Adding ${label}:`, v)

View File

@@ -52,7 +52,7 @@ export function DesktopFilters(props: {
isYourFilters: boolean
locationFilterProps: LocationFilterProps
includeRelationshipFilters: boolean | undefined
choices: Record<OptionTableKey, Record<string, string[]>>
choices: Record<OptionTableKey, Record<string, string>>
}) {
const {
filters,

View File

@@ -3,8 +3,9 @@ import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-te
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {FilterFields} from "common/filters";
import {OptionTableKey} from 'common/profiles/constants'
import {useT} from 'web/lib/locale'
import {toKey} from "common/parsing";
import {useLocale, useT} from 'web/lib/locale'
import {useChoices} from "web/hooks/use-choices";
import {invert} from "lodash";
export function InterestFilterText(props: {
options: string[] | undefined
@@ -14,6 +15,7 @@ export function InterestFilterText(props: {
const {options, highlightedClass, label} = props
const t = useT()
const length = (options ?? []).length
const {choices} = useChoices('interests')
if (!options || length < 1) {
return (
@@ -37,7 +39,7 @@ export function InterestFilterText(props: {
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: options.map((o) => t(`profile.${label}.${toKey(o)}`, o)),
text: options.map(id => choices[id]),
capitalizeFirstLetterOption: true,
t: t,
})}{' '}
@@ -49,18 +51,21 @@ export function InterestFilterText(props: {
export function InterestFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
choices: Record<string, string[]>
choices: Record<string, string>
label: OptionTableKey
}) {
const {filters, updateFilter, choices, label} = props
const {locale} = useLocale()
const sortedChoices = Object.fromEntries(
Object.entries(invert(choices)).sort((a, b) =>
a[0].localeCompare(b[0], locale)
)
)
return (
<MultiCheckbox
selected={filters[label] ?? []}
choices={choices as any}
translationPrefix={`profile.${label}`}
onChange={(c) => {
updateFilter({[label]: c})
}}
choices={sortedChoices as any}
onChange={(c) => updateFilter({[label]: c})}
/>
)
}

View File

@@ -53,7 +53,7 @@ function MobileFilters(props: {
isYourFilters: boolean
locationFilterProps: LocationFilterProps
includeRelationshipFilters: boolean | undefined
choices: Record<OptionTableKey, Record<string, string[]>>
choices: Record<OptionTableKey, Record<string, string>>
}) {
const t = useT()
const {

View File

@@ -3,7 +3,7 @@ import {Title} from 'web/components/widgets/title'
import {Col} from 'web/components/layout/col'
import clsx from 'clsx'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {useLocale, useT} from 'web/lib/locale'
import {Row} from 'web/components/layout/row'
import {Input} from 'web/components/widgets/input'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
@@ -81,11 +81,12 @@ export const OptionalProfileUserForm = (props: {
const [interestChoices, setInterestChoices] = useState({})
const [causeChoices, setCauseChoices] = useState({})
const [workChoices, setWorkChoices] = useState({})
const {locale} = useLocale()
useEffect(() => {
fetchChoices('interests').then(setInterestChoices)
fetchChoices('causes').then(setCauseChoices)
fetchChoices('work').then(setWorkChoices)
fetchChoices('interests', locale).then(setInterestChoices)
fetchChoices('causes', locale).then(setCauseChoices)
fetchChoices('work', locale).then(setWorkChoices)
}, [db])
const handleSubmit = async () => {
@@ -104,14 +105,14 @@ export const OptionalProfileUserForm = (props: {
const promises: Promise<any>[] = [
tryCatch(updateProfile(removeUndefinedProps(otherProfileProps) as any))
]
if (interests?.length) {
promises.push(api('update-options', {table: 'interests', names: interests}))
if (interests) {
promises.push(api('update-options', {table: 'interests', values: interests}))
}
if (causes?.length) {
promises.push(api('update-options', {table: 'causes', names: causes}))
if (causes) {
promises.push(api('update-options', {table: 'causes', values: causes}))
}
if (work?.length) {
promises.push(api('update-options', {table: 'work', names: work}))
if (work) {
promises.push(api('update-options', {table: 'work', values: work}))
}
try {
await Promise.all(promises)

View File

@@ -33,7 +33,7 @@ import {MAX_INT, MIN_INT} from "common/constants";
import {GiFruitBowl} from "react-icons/gi";
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa";
import {useLocale, useT} from "web/lib/locale";
import {toKey} from "common/parsing";
import {useChoices} from "web/hooks/use-choices";
export function AboutRow(props: {
icon: ReactNode
@@ -79,6 +79,11 @@ export default function ProfileAbout(props: {
}) {
const {profile, userActivity, isCurrentUser} = props
const t = useT()
const {choices: interestsById} = useChoices('interests')
const {choices: causesById} = useChoices('causes')
const {choices: workById} = useChoices('work')
const {locale} = useLocale()
return (
<Col
className={clsx('bg-canvas-0 relative gap-3 overflow-hidden rounded p-4')}
@@ -90,7 +95,7 @@ export default function ProfileAbout(props: {
<Occupation profile={profile}/>
<AboutRow
icon={<FaBriefcase className="h-5 w-5"/>}
text={profile.work?.map(work => t(`profile.work.${toKey(work)}`, work))}
text={profile.work?.map(id => workById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]}
/>
<AboutRow
icon={<RiScales3Line className="h-5 w-5"/>}
@@ -104,11 +109,11 @@ export default function ProfileAbout(props: {
/>
<AboutRow
icon={<FaStar className="h-5 w-5"/>}
text={profile.interests?.map(interest => t(`profile.interests.${toKey(interest)}`, interest))}
text={profile.interests?.map(id => interestsById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]}
/>
<AboutRow
icon={<FaHandsHelping className="h-5 w-5"/>}
text={profile.causes?.map(cause => t(`profile.causes.${toKey(cause)}`, cause))}
text={profile.causes?.map(id => causesById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]}
/>
<AboutRow
icon={<BsPersonVcard className="h-5 w-5"/>}

View File

@@ -14,7 +14,7 @@ import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches"
import {useFilters} from "web/components/filters/use-filters"
import {useT} from "web/lib/locale";
import {useLocale, useT} from "web/lib/locale";
export function ProfilesHome() {
const user = useUser()
@@ -35,6 +35,7 @@ export function ProfilesHome() {
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isReloading, setIsReloading] = useState(false)
const t = useT()
const {locale} = useLocale()
// const [debouncedAgeRange, setRawAgeRange] = useState({
// min: filters.pref_age_min ?? PREF_AGE_MIN,
@@ -56,6 +57,7 @@ export function ProfilesHome() {
const args = removeNullOrUndefinedProps({
limit: 20,
compatibleWithUserId: user?.id,
locale,
...filters
})
console.debug('Refreshing profiles, filters:', args)
@@ -87,6 +89,7 @@ export function ProfilesHome() {
limit: 20,
compatibleWithUserId: user?.id,
after: lastProfile?.id.toString(),
locale,
...filters
}) as any)
if (result.profiles.length === 0) return false

View File

@@ -2,20 +2,40 @@ import {useEffect} from 'react'
import {run} from 'common/supabase/utils'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {db} from 'web/lib/supabase/db'
import {useLocale} from "web/lib/locale";
export async function fetchChoices(label: string) {
const {data} = await run(db.from(label).select('name').order('name'))
console.log('Fetched choices:', data)
const results = Object.fromEntries(data.map((row: { name: string }) => [row.name, row.name]))
return results;
export async function fetchChoices(label: string, locale: string) {
let choicesById: Record<string, string> = {};
const {data} = await run(
db
.from(label)
.select(`
id,
name,
${label}_translations!left (
locale,
name
)
`)
.eq(`${label}_translations.locale`, locale)
.order('name', {ascending: true})
)
data.forEach((row: any) => {
const translated = row[`${label}_translations`]?.[0]?.name ?? row.name
choicesById[row.id] = translated
})
return choicesById
}
export const useChoices = (label: string) => {
const [choices, setChoices] = usePersistentInMemoryState({}, `${label}-choices`)
const [choices, setChoices] = usePersistentInMemoryState<Record<string, string>>({}, `${label}-choices`)
const {locale} = useLocale()
const refreshChoices = async () => {
try {
const results = await fetchChoices(label)
const results = await fetchChoices(label, locale)
setChoices(results)
} catch (err) {
console.error('Error fetching choices:', err)
@@ -26,7 +46,7 @@ export const useChoices = (label: string) => {
useEffect(() => {
console.log('Fetching choices in use effect...')
refreshChoices()
}, [])
}, [locale])
return {choices, refreshChoices}
}

View File

@@ -388,20 +388,6 @@
"profile.bio.tips_list": "- Ihre Grundwerte, Interessen und Aktivitäten\n- Persönlichkeitsmerkmale, was Sie einzigartig macht und was Ihnen wichtig ist\n- Verbindungsziele (Zusammenarbeit, Freundschaft, romantisch)\n- Erwartungen und Grenzen\n- Verfügbarkeit, wie man Sie kontaktieren oder ein Gespräch beginnen kann (E-Mail, soziale Netzwerke, etc.)\n- Optional: romantische Vorlieben, Lebensgewohnheiten und Gesprächsthemen",
"profile.bio.too_short": "Bio zu kurz. Das Profil kann aus den Suchergebnissen gefiltert werden.",
"profile.bio.too_short_tooltip": "Da Ihre Bio zu kurz ist, filtert der Compass-Algorithmus Ihr Profil aus den Suchergebnissen (außer „Kurze Bios einbeziehen“ ist ausgewählt). Dies stellt sicher, dass Suchergebnisse aussagekräftige Profile anzeigen.",
"profile.causes.ai_safety": "KI-Sicherheit",
"profile.causes.animal_rights": "Tierrechte",
"profile.causes.animal_welfare": "Tierschutz",
"profile.causes.biodiversity": "Biodiversität",
"profile.causes.cause_agnostic": "Ursachenagnostisch",
"profile.causes.climate_change": "Klimawandel",
"profile.causes.degrowth": "Degrowth",
"profile.causes.disaster_relief": "Katastrophenhilfe",
"profile.causes.education": "Bildung",
"profile.causes.extreme_poverty": "Extreme Armut",
"profile.causes.gender_equality": "Gleichstellung der Geschlechter",
"profile.causes.human_rights": "Menschenrechte",
"profile.causes.mental_health": "Psychische Gesundheit",
"profile.causes.sustainability": "Nachhaltigkeit",
"profile.comments.current_user_hint": "Andere Benutzer können Ihnen hier Empfehlungen hinterlassen.",
"profile.comments.add_comment": "Kommentar hinzufügen",
"profile.comments.placeholder": "Schreiben Sie Ihre Empfehlung...",
@@ -461,33 +447,6 @@
"profile.header.toast.failed_enable": "Aktivierung des Profils fehlgeschlagen",
"profile.info.signup_to_see": "Registrieren Sie sich, um das Profil zu sehen",
"profile.interested_in": "Interessiert an",
"profile.interests.chess": "Schach",
"profile.interests.coding": "Programmieren",
"profile.interests.collecting": "Sammeln",
"profile.interests.cooking": "Kochen",
"profile.interests.crafting": "Handwerk",
"profile.interests.crochet": "Häkeln",
"profile.interests.dancing": "Tanzen",
"profile.interests.drawing": "Zeichnen",
"profile.interests.game_design": "Spieleentwicklung",
"profile.interests.gardening": "Gärtnern",
"profile.interests.guitar": "Gitarre",
"profile.interests.gym": "Fitnessstudio",
"profile.interests.hiking": "Wandern",
"profile.interests.meditation": "Meditation",
"profile.interests.music_listening": "Musik hören",
"profile.interests.music_playing": "Musik spielen",
"profile.interests.painting": "Malen",
"profile.interests.philosophy": "Philosophie",
"profile.interests.photography": "Fotografie",
"profile.interests.piano": "Klavier",
"profile.interests.pottery": "Töpfern",
"profile.interests.reading": "Lesen",
"profile.interests.running": "Laufen",
"profile.interests.traveling": "Reisen",
"profile.interests.video_games": "Videospiele",
"profile.interests.writing": "Schreiben",
"profile.interests.yoga": "Yoga",
"profile.language.akan": "Akan",
"profile.language.amharic": "Amharisch",
"profile.language.arabic": "Arabisch",
@@ -705,34 +664,6 @@
"profile.wants_kids_2": "Neutral oder offen für Kinder",
"profile.wants_kids_3": "Neigt dazu, Kinder zu wollen",
"profile.wants_kids_4": "Möchte Kinder",
"profile.work.academia": "Akademischer Bereich",
"profile.work.agriculture": "Landwirtschaft",
"profile.work.construction": "Baugewerbe",
"profile.work.consulting": "Beratung",
"profile.work.content_design": "Content-Design",
"profile.work.data_science": "Data Science",
"profile.work.education": "Bildung",
"profile.work.engineering": "Ingenieurwesen",
"profile.work.fashion": "Mode",
"profile.work.film": "Film",
"profile.work.finance": "Finanzen",
"profile.work.graphic_design": "Grafikdesign",
"profile.work.human_resources": "Personalwesen",
"profile.work.it": "IT",
"profile.work.journalism": "Journalismus",
"profile.work.law": "Recht",
"profile.work.management": "Management",
"profile.work.marketing": "Marketing",
"profile.work.medicine": "Medizin",
"profile.work.music": "Musik",
"profile.work.nursing": "Krankenpflege",
"profile.work.research_and_design": "Forschung und Design",
"profile.work.sales": "Vertrieb",
"profile.work.social_enterprise": "Sozialunternehmen",
"profile.work.social_work": "Soziale Arbeit",
"profile.work.spirits_maker": "Spirituosenherstellung",
"profile.work.sports": "Sport",
"profile.work.tourism": "Tourismus",
"profile_grid.no_profiles": "Keine Profile gefunden.",
"profile_grid.notification_cta": "Klicken Sie gern auf „Benachrichtigen“, und wir informieren Sie, sobald neue Nutzer Ihrer Suche entsprechen.",
"profiles.title": "Personen",

View File

@@ -388,20 +388,6 @@
"profile.bio.tips_list": "- Vos valeurs fondamentales, intérêts et activités\n- Traits de personnalité, ce qui vous rend unique et ce qui vous tient à cœur\n- Objectifs de connexion (collaboration, amitié, romantique)\n- Attentes et limites\n- Disponibilité, comment vous contacter ou démarrer une conversation (e-mail, réseaux sociaux, etc.)\n- Optionnel : préférences romantiques, habitudes de vie et sujets de conversation",
"profile.bio.too_short": "Bio trop courte. Le profil peut être filtré des résultats de recherche.",
"profile.bio.too_short_tooltip": "Comme votre bio est trop courte, l'algorithme de Compass filtre votre profil des résultats de recherche (sauf si « Inclure les bios courtes » est sélectionné). Cela garantit que les recherches affichent des profils significatifs.",
"profile.causes.ai_safety": "Sécurité de l'IA",
"profile.causes.animal_rights": "Droits des animaux",
"profile.causes.animal_welfare": "Bien-être animal",
"profile.causes.biodiversity": "Biodiversité",
"profile.causes.cause_agnostic": "Cause agnostique",
"profile.causes.climate_change": "Changement climatique",
"profile.causes.degrowth": "Décroissance",
"profile.causes.disaster_relief": "Secours en cas de catastrophe",
"profile.causes.education": "Éducation",
"profile.causes.extreme_poverty": "Pauvreté extrême",
"profile.causes.gender_equality": "Égalité des genres",
"profile.causes.human_rights": "Droits humains",
"profile.causes.mental_health": "Santé mentale",
"profile.causes.sustainability": "Durabilité",
"profile.comments.current_user_hint": "Les autres utilisateurs peuvent vous laisser des recommandations ici.",
"profile.comments.add_comment": "Ajouter un commentaire",
"profile.comments.placeholder": "Écrivez votre recommandation...",
@@ -461,33 +447,6 @@
"profile.header.toast.failed_enable": "Échec de l'activation du profil",
"profile.info.signup_to_see": "Inscrivez-vous pour voir le profil",
"profile.interested_in": "Intéressé·e par",
"profile.interests.chess": "Échecs",
"profile.interests.coding": "Programmation",
"profile.interests.collecting": "Collection",
"profile.interests.cooking": "Cuisine",
"profile.interests.crafting": "Artisanat",
"profile.interests.crochet": "Crochet",
"profile.interests.dancing": "Danse",
"profile.interests.drawing": "Dessin",
"profile.interests.game_design": "Conception de jeux",
"profile.interests.gardening": "Jardinage",
"profile.interests.guitar": "Guitare",
"profile.interests.gym": "Salle de sport",
"profile.interests.hiking": "Randonnée",
"profile.interests.meditation": "Méditation",
"profile.interests.music_listening": "Écoute de musique",
"profile.interests.music_playing": "Pratique musicale",
"profile.interests.painting": "Peinture",
"profile.interests.philosophy": "Philosophie",
"profile.interests.photography": "Photographie",
"profile.interests.piano": "Piano",
"profile.interests.pottery": "Poterie",
"profile.interests.reading": "Lecture",
"profile.interests.running": "Course à pied",
"profile.interests.traveling": "Voyage",
"profile.interests.video_games": "Jeux vidéo",
"profile.interests.writing": "Écriture",
"profile.interests.yoga": "Yoga",
"profile.language.akan": "Akan",
"profile.language.amharic": "Amharique",
"profile.language.arabic": "Arabe",
@@ -705,36 +664,8 @@
"profile.wants_kids_2": "Neutre ou ouvert·e à avoir des enfants",
"profile.wants_kids_3": "Tend à vouloir des enfants",
"profile.wants_kids_4": "Veut des enfants",
"profile.work.academia": "Monde universitaire",
"profile.work.agriculture": "Agriculture",
"profile.work.construction": "Construction",
"profile.work.consulting": "Conseil",
"profile.work.content_design": "Design de contenu",
"profile.work.data_science": "Science des données",
"profile.work.education": "Éducation",
"profile.work.engineering": "Ingénierie",
"profile.work.fashion": "Mode",
"profile.work.film": "Cinéma",
"profile.work.finance": "Finance",
"profile.work.graphic_design": "Design graphique",
"profile.work.human_resources": "Ressources humaines",
"profile.work.it": "Informatique",
"profile.work.journalism": "Journalisme",
"profile.work.law": "Droit",
"profile.work.management": "Gestion",
"profile.work.marketing": "Marketing",
"profile.work.medicine": "Médecine",
"profile.work.music": "Musique",
"profile.work.nursing": "Soins infirmiers",
"profile.work.research_and_design": "Recherche et conception",
"profile.work.sales": "Vente",
"profile.work.social_enterprise": "Entreprise sociale",
"profile.work.social_work": "Travail social",
"profile.work.spirits_maker": "Fabricant de spiritueux",
"profile.work.sports": "Sport",
"profile.work.tourism": "Tourisme",
"profile_grid.no_profiles": "Aucun profil trouvé.",
"profile_grid.notification_cta": "N'hésitez pas à cliquer sur \"Être notif\" et nous vous préviendrons quand de nouveaux utilisateurs correspondront à votre recherche !",
"profile_grid.notification_cta": "N'hésitez pas à cliquer sur \"Recevoir notifs\" et nous vous préviendrons quand de nouveaux utilisateurs correspondront à votre recherche !",
"profiles.title": "Personnes",
"register.agreement.and": " et ",
"register.agreement.prefix": "En vous inscrivant, j'accepte les ",
@@ -782,7 +713,7 @@
"saved_people.list_header": "Voici les personnes que vous avez enregistrées :",
"saved_people.title": "Personnes enregistrées",
"saved_searches.button": "Recherches enregistrées",
"saved_searches.empty_state": "Vous n'avez enregistré aucune recherche. Pour en enregistrer une, cliquez sur Être notif et nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront.",
"saved_searches.empty_state": "Vous n'avez enregistré aucune recherche. Pour en enregistrer une, cliquez sur Recevoir notifs et nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront.",
"saved_searches.notification_note": "Nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront à vos recherches ci-dessous.",
"saved_searches.title": "Recherches enregistrées",
"security.contact.email_button": "Email",