Make answers to compatibility prompts searchable

This commit is contained in:
MartinBraquet
2026-02-19 14:10:41 +01:00
parent c1a204e3be
commit 27e93da06a
6 changed files with 192 additions and 73 deletions

View File

@@ -3,20 +3,42 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
export function shuffle<T>(array: T[]): T[] {
const arr = [...array]; // copy to avoid mutating the original
const arr = [...array] // copy to avoid mutating the original
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr;
return arr
}
export const getCompatibilityQuestions: APIHandler<
'get-compatibility-questions'
> = async (props, _auth) => {
const {locale = 'en'} = props
const {locale = 'en', keyword} = props
const pg = createSupabaseDirectClient()
// Build query parameters
const params: (string | number)[] = [locale]
const paramIndex = 2
// Build keyword filter condition - search in question text and multiple_choice_options keys
const keywordFilter = keyword
? `AND (
COALESCE(cpt.question, cp.question) ILIKE $${paramIndex}
OR EXISTS (
SELECT 1
FROM jsonb_object_keys(
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options)
) AS option_key
WHERE option_key ILIKE $${paramIndex}
)
)`
: ''
if (keyword) {
params.push(`%${keyword}%`)
}
const questions = await pg.manyOrNone<
Row<'compatibility_prompts'> & { answer_count: number; score: number }
>(
@@ -52,6 +74,7 @@ export const getCompatibilityQuestions: APIHandler<
AND $1 <> 'en'
WHERE cp.answer_type = 'compatibility_multiple_choice'
${keywordFilter}
GROUP BY cp.id,
cpt.question,
@@ -59,7 +82,7 @@ export const getCompatibilityQuestions: APIHandler<
ORDER BY cp.importance_score
`,
[locale]
params
)
// console.debug({questions})

View File

@@ -1,7 +1,7 @@
import {arraybeSchema, baseProfilesSchema, combinedProfileSchema, contentSchema, zBoolean,} from 'common/api/zod-types'
import {PrivateChatMessage} from 'common/chat-message'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants'
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES,} from 'common/profiles/constants'
import {Profile, ProfileRow} from 'common/profiles/profile'
import {Row} from 'common/supabase/utils'
import {PrivateUser, User} from 'common/user'
@@ -49,7 +49,12 @@ export const API = (_apiTypeCheck = {
message: 'Server is working.'
uid?: string
version?: string
git?: { revision?: string; commitDate?: string; author?: string, message?: string }
git?: {
revision?: string
commitDate?: string
author?: string
message?: string
}
},
summary: 'Check whether the API server is running',
tag: 'General',
@@ -342,7 +347,8 @@ export const API = (_apiTypeCheck = {
authed: true,
rateLimited: false,
props: z.object({
locale: z.string().optional()
locale: z.string().optional(),
keyword: z.string().optional(),
}),
returns: {} as {
status: 'success'
@@ -470,10 +476,12 @@ export const API = (_apiTypeCheck = {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({
limit: z.coerce.number().min(1).max(200).optional(),
offset: z.coerce.number().min(0).optional(),
}).strict(),
props: z
.object({
limit: z.coerce.number().min(1).max(200).optional(),
offset: z.coerce.number().min(0).optional(),
})
.strict(),
returns: {} as {
status: 'success'
hidden: {
@@ -545,8 +553,8 @@ export const API = (_apiTypeCheck = {
.strict(),
returns: {} as {
status: 'success' | 'fail'
profiles: Profile[],
count: number,
profiles: Profile[]
count: number
},
summary: 'List profiles with filters, pagination and ordering',
tag: 'Profiles',
@@ -788,7 +796,7 @@ export const API = (_apiTypeCheck = {
summary: 'Create a new vote/poll',
tag: 'Votes',
},
'vote': {
vote: {
method: 'POST',
authed: true,
rateLimited: true,
@@ -825,7 +833,7 @@ export const API = (_apiTypeCheck = {
summary: 'Find places near a GeoDB city ID within a radius',
tag: 'Locations',
},
'contact': {
contact: {
method: 'POST',
authed: false,
rateLimited: true,
@@ -852,7 +860,7 @@ export const API = (_apiTypeCheck = {
rateLimited: true,
returns: {} as any,
props: z.object({
subscription: z.record(z.any())
subscription: z.record(z.any()),
}),
summary: 'Save a push/browser subscription for the user',
tag: 'Notifications',
@@ -873,12 +881,11 @@ export const API = (_apiTypeCheck = {
authed: true,
rateLimited: true,
returns: {} as Row<'bookmarked_searches'>,
props: z
.object({
search_filters: z.any().optional(),
location: z.any().optional(),
search_name: z.string().nullable().optional(),
}),
props: z.object({
search_filters: z.any().optional(),
location: z.any().optional(),
search_name: z.string().nullable().optional(),
}),
summary: 'Create a bookmarked search for quick reuse',
tag: 'Searches',
},
@@ -988,11 +995,12 @@ export type ValidatedAPIParams<N extends APIPath> = z.output<
APISchema<N>['props']
>
export type APIResponse<N extends APIPath> = APISchema<N> extends {
returns: Record<string, any>
}
? APISchema<N>['returns']
: void
export type APIResponse<N extends APIPath> =
APISchema<N> extends {
returns: Record<string, any>
}
? APISchema<N>['returns']
: void
export type APIResponseOptionalContinue<N extends APIPath> =
| { continue: () => Promise<void>; result: APIResponse<N> }

View File

@@ -10,7 +10,7 @@ import {
} from 'web/lib/supabase/questions'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {api} from 'web/lib/api'
import {useLocale} from "web/lib/locale";
import {useLocale} from 'web/lib/locale'
export const useQuestions = () => {
const [questions, setQuestions] = useState<Row<'compatibility_prompts'>[]>([])
@@ -44,7 +44,7 @@ export const useUserAnswers = (userId: string | undefined) => {
getUserAnswers(userId).then(setAnswers)
}
return { refreshAnswers, answers }
return {refreshAnswers, answers}
}
export const useUserCompatibilityAnswers = (userId: string | undefined) => {
@@ -72,7 +72,7 @@ export const useUserCompatibilityAnswers = (userId: string | undefined) => {
getUserCompatibilityAnswers(userId).then(setCompatibilityAnswers)
}
return { refreshCompatibilityAnswers, compatibilityAnswers }
return {refreshCompatibilityAnswers, compatibilityAnswers}
}
export type QuestionWithCountType = Row<'compatibility_prompts'> & {
@@ -93,26 +93,32 @@ export const useFRQuestionsWithAnswerCount = () => {
return FRquestionsWithCount as QuestionWithCountType[]
}
export const useCompatibilityQuestionsWithAnswerCount = () => {
export const useCompatibilityQuestionsWithAnswerCount = (keyword?: string) => {
const {locale} = useLocale()
const [compatibilityQuestions, setCompatibilityQuestions] =
usePersistentInMemoryState<QuestionWithCountType[]>(
[],
`compatibility-questions-with-count`
)
const [isLoading, setIsLoading] = useState(true)
async function refreshCompatibilityQuestions() {
return api('get-compatibility-questions', {locale}).then((res) => {
setCompatibilityQuestions(res.questions)
})
setIsLoading(true)
return api('get-compatibility-questions', {locale, keyword}).then(
(res) => {
setCompatibilityQuestions(res.questions)
setIsLoading(false)
}
)
}
useEffect(() => {
refreshCompatibilityQuestions()
}, [locale])
}, [locale, keyword])
return {
refreshCompatibilityQuestions,
compatibilityQuestions,
isLoading,
}
}

View File

@@ -149,6 +149,9 @@
"compatibility.tabs.to_answer": "Zu beantworten",
"compatibility.title": "Ihre Kompatibilitätsfragen",
"compatibility.tooltip": "Kompatibilitätswert zwischen Ihnen beiden",
"compatibility.empty.no_results": "Keine Ergebnisse für \"{keyword}\"",
"compatibility.seo.title": "Kompatibilität",
"compatibility.seo.description": "Ihre Kompatibilitätsfragen anzeigen und verwalten",
"contact.editor.placeholder": "Kontaktieren Sie uns hier...",
"contact.form_link": "Feedback-Formular",
"contact.intro_middle": " oder über unsere ",
@@ -1136,5 +1139,6 @@
"filter.label.diet": "Ernährung",
"filter.label.political_beliefs": "Politische Ansichten",
"filter.label.mbti": "MBTI",
"filter.drinks.per_month": "pro Monat"
"filter.drinks.per_month": "pro Monat",
"compatibility.search_placeholder": "Fragen und Antworten durchsuchen..."
}

View File

@@ -1136,5 +1136,9 @@
"filter.label.diet": "Régime",
"filter.label.political_beliefs": "Opinions politiques",
"filter.label.mbti": "MBTI",
"filter.drinks.per_month": "par mois"
"filter.drinks.per_month": "par mois",
"compatibility.search_placeholder": "Rechercher des questions et réponses...",
"compatibility.seo.title": "Compatibilité",
"compatibility.seo.description": "Afficher et gérer vos questions de compatibilité",
"compatibility.empty.no_results": "Aucun résultat pour \"{keyword}\""
}

View File

@@ -1,18 +1,22 @@
import {useUser} from 'web/hooks/use-user'
import {useCompatibilityQuestionsWithAnswerCount, useUserCompatibilityAnswers} from 'web/hooks/use-questions'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {useCompatibilityQuestionsWithAnswerCount, useUserCompatibilityAnswers,} from 'web/hooks/use-questions'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Row} from 'common/supabase/utils'
import {Question} from 'web/lib/supabase/questions'
import {Col} from 'web/components/layout/col'
import {Title} from 'web/components/widgets/title'
import {PageBase} from "web/components/page-base";
import {UncontrolledTabs} from "web/components/layout/tabs";
import {CompatibilityAnswerBlock} from "web/components/answers/compatibility-questions-display";
import {User} from "common/user";
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
import {useIsMobile} from "web/hooks/use-is-mobile";
import {LoadMoreUntilNotVisible} from "web/components/widgets/visibility-observer";
import {PageBase} from 'web/components/page-base'
import {UncontrolledTabs} from 'web/components/layout/tabs'
import {CompatibilityAnswerBlock} from 'web/components/answers/compatibility-questions-display'
import {User} from 'common/user'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
import {useT} from 'web/lib/locale'
import {Input} from 'web/components/widgets/input'
import {debounce} from 'lodash'
import clsx from "clsx";
import {SEO} from "web/components/SEO";
type QuestionWithAnswer = Question & {
answer?: Row<'compatibility_answers'>
@@ -24,11 +28,27 @@ export default function CompatibilityPage() {
const user = useUser()
const isMobile = useIsMobile()
const sep = isMobile ? '\n' : ''
const {compatibilityAnswers, refreshCompatibilityAnswers} = useUserCompatibilityAnswers(user?.id)
const {compatibilityQuestions, refreshCompatibilityQuestions} = useCompatibilityQuestionsWithAnswerCount()
const [isLoading, setIsLoading] = useState(true)
const [keyword, setKeyword] = useState('')
const [debouncedKeyword, setDebouncedKeyword] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const {compatibilityAnswers, refreshCompatibilityAnswers} =
useUserCompatibilityAnswers(user?.id)
const {compatibilityQuestions, refreshCompatibilityQuestions, isLoading} =
useCompatibilityQuestionsWithAnswerCount(debouncedKeyword || undefined)
const t = useT()
// Debounce keyword changes
const debouncedSetKeyword = useMemo(
() => debounce((value: string) => setDebouncedKeyword(value), 500),
[]
)
useEffect(() => {
debouncedSetKeyword(keyword)
// Cleanup debounce on unmount
return () => debouncedSetKeyword.cancel()
}, [keyword, debouncedSetKeyword])
const questionsWithAnswers = useMemo(() => {
if (!compatibilityQuestions) return []
@@ -36,12 +56,14 @@ export default function CompatibilityPage() {
compatibilityAnswers?.map((a) => [a.question_id, a]) ?? []
)
return compatibilityQuestions.map((q) => ({
...q,
answer: answerMap.get(q.id),
})).sort(
(a, b) => a.importance_score - b.importance_score
) as QuestionWithAnswer[]
return compatibilityQuestions
.map((q) => ({
...q,
answer: answerMap.get(q.id),
}))
.sort(
(a, b) => a.importance_score - b.importance_score
) as QuestionWithAnswer[]
}, [compatibilityQuestions, compatibilityAnswers])
const {answered, notAnswered, skipped} = useMemo(() => {
@@ -69,7 +91,7 @@ export default function CompatibilityPage() {
Promise.all([
refreshCompatibilityAnswers(),
refreshCompatibilityQuestions(),
]).finally(() => setIsLoading(false))
]).finally(() => console.log('refreshed compatibility'))
}
}, [user?.id])
@@ -80,15 +102,34 @@ export default function CompatibilityPage() {
return (
<PageBase trackPageView={'compatibility'}>
{user ?
<SEO
title={t('compatibility.seo.title', 'Compatibility')}
description={t('compatibility.seo.description', 'View and manage your compatibility questions')}
url={`/compatibility`}
/>
{user ? (
<Col className="w-full p-4">
<Title className="mb-4">{t('compatibility.title','Your Compatibility Questions')}</Title>
<Title className="mb-4">
{t('compatibility.title', 'Your Compatibility Questions')}
</Title>
<Input
ref={searchInputRef}
value={keyword}
placeholder={t(
'compatibility.search_placeholder',
'Search questions and answers...'
)}
className={'w-full max-w-xs mb-4'}
onChange={(e) => {
setKeyword(e.target.value)
}}
/>
<UncontrolledTabs
trackingName={'compatibility page'}
name={'compatibility-page'}
tabs={[
{
title: `${t('compatibility.tabs.answered','Answered')} ${sep}(${answered.length})`,
title: `${t('compatibility.tabs.answered', 'Answered')} ${sep}(${answered.length})`,
content: (
<QuestionList
questions={answered}
@@ -96,11 +137,12 @@ export default function CompatibilityPage() {
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
keyword={keyword}
/>
),
},
{
title: `${t('compatibility.tabs.to_answer','To Answer')} ${sep}(${notAnswered.length})`,
title: `${t('compatibility.tabs.to_answer', 'To Answer')} ${sep}(${notAnswered.length})`,
content: (
<QuestionList
questions={notAnswered}
@@ -108,11 +150,12 @@ export default function CompatibilityPage() {
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
keyword={keyword}
/>
),
},
{
title: `${t('compatibility.tabs.skipped','Skipped')} ${sep}(${skipped.length})`,
title: `${t('compatibility.tabs.skipped', 'Skipped')} ${sep}(${skipped.length})`,
content: (
<QuestionList
questions={skipped}
@@ -120,17 +163,23 @@ export default function CompatibilityPage() {
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
keyword={keyword}
/>
),
},
]}
/>
</Col>
:
) : (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-xl">{t('compatibility.sign_in_prompt','Please sign in to view your compatibility questions')}</div>
<div className="text-xl">
{t(
'compatibility.sign_in_prompt',
'Please sign in to view your compatibility questions'
)}
</div>
</div>
}
)}
</PageBase>
)
}
@@ -141,12 +190,14 @@ function QuestionList({
isLoading,
user,
refreshCompatibilityAll,
keyword,
}: {
questions: QuestionWithAnswer[]
status: 'answered' | 'not-answered' | 'skipped'
isLoading: boolean
user: User
refreshCompatibilityAll: () => void
keyword: string
}) {
const t = useT()
const BATCH_SIZE = 100
@@ -165,18 +216,38 @@ function QuestionList({
setVisibleCount((prev) => Math.min(prev + BATCH_SIZE, questions.length))
console.log('end loadMore')
return true
}, [visibleCount, questions.length]);
}, [visibleCount, questions.length])
if (isLoading) {
if (isLoading && questions.length === 0) {
return <CompassLoadingIndicator/>
}
if (questions.length === 0) {
if (!isLoading && questions.length === 0) {
return (
<div className="text-ink-500 p-4">
{status === 'answered' && t('compatibility.empty.answered',"You haven't answered any questions yet.")}
{status === 'not-answered' && t('compatibility.empty.not_answered',"All questions have been answered!")}
{status === 'skipped' && t('compatibility.empty.skipped',"You haven't skipped any questions.")}
{keyword ? (
t('compatibility.empty.no_results', 'No results for "{keyword}"', {
keyword,
})
) : (
<>
{status === 'answered' &&
t(
'compatibility.empty.answered',
"You haven't answered any questions yet."
)}
{status === 'not-answered' &&
t(
'compatibility.empty.not_answered',
'All questions have been answered!'
)}
{status === 'skipped' &&
t(
'compatibility.empty.skipped',
"You haven't skipped any questions."
)}
</>
)}
</div>
)
}
@@ -188,7 +259,10 @@ function QuestionList({
{visibleQuestions.map((q) => (
<div
key={q.id}
className="bg-canvas-0 border-canvas-100 rounded-lg border px-2 pt-2 shadow-sm transition-colors"
className={clsx(
"bg-canvas-0 border-canvas-100 rounded-lg border px-2 pt-2 shadow-sm transition-colors",
isLoading && 'animate-pulse opacity-80'
)}
>
<CompatibilityAnswerBlock
key={q.answer?.question_id}