Sort compat prompts by community importance and answer count

This commit is contained in:
MartinBraquet
2026-03-09 02:27:10 +01:00
parent 94585b1f1d
commit 24d2fe9c32
17 changed files with 338 additions and 179 deletions

View File

@@ -21,6 +21,8 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
throw APIErrors.notFound('Item not found')
}
const questionId = item.question_id
// Delete the answer
await pg.none(
`DELETE
@@ -31,12 +33,16 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
)
const continuation = async () => {
// Update importance counts for the question
await pg.oneOrNone('SELECT update_compatibility_prompt_community_importance_score($1)', [
questionId,
])
// Recompute precomputed compatibility scores for this user
await recomputeCompatibilityScoresForUser(auth.uid, pg)
}
return {
status: 'success',
result: {status: 'success'},
continue: continuation,
}
}

View File

@@ -2,15 +2,6 @@ import {type APIHandler} from 'api/helpers/endpoint'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export function shuffle<T>(array: T[]): T[] {
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]]
}
return arr
}
export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'> = async (
props,
_auth,
@@ -40,9 +31,7 @@ export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'
params.push(`%${keyword}%`)
}
const questions = await pg.manyOrNone<
Row<'compatibility_prompts'> & {answer_count: number; score: number}
>(
const questions = await pg.manyOrNone<Row<'compatibility_prompts'> & {score: number}>(
`
SELECT cp.id,
cp.answer_type,
@@ -50,12 +39,12 @@ export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'
cp.created_time,
cp.creator_id,
cp.category,
cp.community_importance_score,
cp.answer_count,
-- locale-aware fields
COALESCE(cpt.question, cp.question) AS question,
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options) AS multiple_choice_options,
COUNT(ca.question_id) AS answer_count,
AVG(
POWER(
ca.importance + 1 +
@@ -86,14 +75,7 @@ export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'
params,
)
// console.debug({questions})
// const questions = shuffle(dbQuestions)
// console.debug(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )
// debug({questions})
return {
status: 'success',

View File

@@ -26,6 +26,10 @@ export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = as
})
const continuation = async () => {
// Update importance counts for the question
await pg.oneOrNone('SELECT update_compatibility_prompt_community_importance_score($1)', [
questionId,
])
// Recompute precomputed compatibility scores for this user
await recomputeCompatibilityScoresForUser(auth.uid, pg)
}

View File

@@ -52,4 +52,5 @@ BEGIN;
\i backend/supabase/migrations/20260223_add_notification_template_translations.sql
\i backend/supabase/migrations/20260224_add_connection_preferences.sql
\i backend/supabase/migrations/20260225_add_keywords_to_profiles.sql
\i backend/supabase/migrations/20260308_add_importance_counts.sql
COMMIT;

View File

@@ -0,0 +1,42 @@
-- Add importance count columns to compatibility_prompts
ALTER TABLE compatibility_prompts
ADD COLUMN IF NOT EXISTS community_importance_score BIGINT DEFAULT 0;
ALTER TABLE compatibility_prompts
ADD COLUMN IF NOT EXISTS answer_count BIGINT DEFAULT 0;
-- Create function to update importance counts for a question
CREATE OR REPLACE FUNCTION update_compatibility_prompt_community_importance_score(p_question_id BIGINT)
RETURNS void
LANGUAGE plpgsql
AS
$$
BEGIN
UPDATE compatibility_prompts cp
SET answer_count = sub.total,
community_importance_score = sub.important
FROM (SELECT COUNT(*) as total,
SUM(CASE WHEN importance >= 2 THEN importance - 1 ELSE 0 END) AS important
FROM compatibility_answers
WHERE question_id = p_question_id
AND multiple_choice IS NOT NULL
AND multiple_choice >= 0) sub
WHERE cp.id = p_question_id;
END;
$$;
-- Backfill existing data
UPDATE compatibility_prompts cp
SET answer_count = sub.total,
community_importance_score = sub.important
FROM (SELECT question_id,
COUNT(*) as total,
SUM(CASE WHEN importance >= 2 THEN importance - 1 ELSE 0 END) AS important
FROM compatibility_answers
WHERE multiple_choice IS NOT NULL
AND multiple_choice >= 0
GROUP BY question_id) sub
WHERE cp.id = sub.question_id;
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_compatibility_prompts_importance_counts
ON compatibility_prompts (answer_count DESC, community_importance_score DESC);

View File

@@ -142,6 +142,10 @@
"compatibility.tabs.to_answer": "Zu beantworten",
"compatibility.title": "Ihre Kompatibilitätsfragen",
"compatibility.tooltip": "Kompatibilitätswert zwischen Ihnen beiden",
"compatibility.sort.random": "Zufällig",
"compatibility.sort.importance": "Gesellschaftliche Wichtigkeit",
"compatibility.sort.most_answered": "Am meisten beantwortet",
"compatibility.sort.newest": "Neueste",
"contact.editor.placeholder": "Kontaktieren Sie uns hier...",
"contact.form_link": "Feedback-Formular",
"contact.intro_middle": " oder über unsere ",

View File

@@ -142,6 +142,10 @@
"compatibility.tabs.to_answer": "À répondre",
"compatibility.title": "Vos questions de compatibilité",
"compatibility.tooltip": "Compatibilité entre vous deux",
"compatibility.sort.random": "Aléatoire",
"compatibility.sort.importance": "Importance communautaire",
"compatibility.sort.most_answered": "Les plus répondues",
"compatibility.sort.newest": "Plus récentes",
"contact.editor.placeholder": "Contactez-nous ici...",
"contact.form_link": "formulaire de retour",
"contact.intro_middle": " ou via nos ",

View File

@@ -583,6 +583,7 @@ export const API = (_apiTypeCheck = {
questions: (Row<'compatibility_prompts'> & {
answer_count: number
score: number
community_importance_score?: number
})[]
},
summary: 'Retrieve compatibility questions and stats',

View File

@@ -187,6 +187,8 @@ export type Database = {
importance_score: number
multiple_choice_options: Json | null
question: string
community_importance_score: number
answer_count: number
}
Insert: {
answer_type?: string
@@ -197,6 +199,8 @@ export type Database = {
importance_score?: number
multiple_choice_options?: Json | null
question: string
community_importance_score?: number
answer_count?: number
}
Update: {
answer_type?: string
@@ -207,6 +211,8 @@ export type Database = {
importance_score?: number
multiple_choice_options?: Json | null
question?: string
community_importance_score?: number
answer_count?: number
}
Relationships: [
{

View File

@@ -47,3 +47,12 @@ export function fallbackIfEmpty<T>(array: T[], fallback: any) {
if (!Array.isArray(array)) return fallback
return array.length > 0 ? array : fallback
}
export function shuffle<T>(array: T[]): T[] {
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]]
}
return arr
}

View File

@@ -8,6 +8,7 @@ import {sortBy} from 'lodash'
import {useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {CompatibilitySort, CompatibilitySortWidget} from 'web/components/compatibility/sort-widget'
import {Col} from 'web/components/layout/col'
import {SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
@@ -118,6 +119,7 @@ export function AnswerCompatibilityQuestionContent(props: {
(props.answer as CompatibilityAnswerSubmitType) ??
getEmptyAnswer(user.id, compatibilityQuestion.id),
)
const [sort, setSort] = useState<CompatibilitySort>('random')
const [loading, setLoading] = useState(false)
const [skipLoading, setSkipLoading] = useState(false)
@@ -160,9 +162,8 @@ export function AnswerCompatibilityQuestionContent(props: {
</span>
</Row>
)}
<div data-testid="compatibility-question">{compatibilityQuestion.question}</div>
{shortenedPopularity && (
<Row className="text-ink-500 select-none items-center text-sm">
<Row>
{shortenedPopularity && (
<Tooltip
text={t(
'answers.content.people_answered',
@@ -170,11 +171,21 @@ export function AnswerCompatibilityQuestionContent(props: {
{count: String(shortenedPopularity)},
)}
>
{shortenedPopularity}
<Row className="text-ink-500 select-none items-center text-sm">
{shortenedPopularity}
<UserIcon className="h-4 w-4" />
</Row>
</Tooltip>
<UserIcon className="h-4 w-4" />
</Row>
)}
)}
<CompatibilitySortWidget
className="text-sm sm:flex ml-auto"
sort={sort}
setSort={setSort}
user={user}
ignore={['your_important']}
/>
</Row>
<div data-testid="compatibility-question">{compatibilityQuestion.question}</div>
</Col>
<Col className={clsx(SCROLLABLE_MODAL_CLASS, 'w-full gap-4 flex-1 min-h-0 pr-2')}>
<Col className="gap-2">

View File

@@ -1,4 +1,3 @@
import {MagnifyingGlassIcon} from '@heroicons/react/16/solid'
import {PencilIcon, TrashIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {
@@ -8,15 +7,21 @@ import {
import {Profile} from 'common/profiles/profile'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {buildArray} from 'common/util/array'
import {keyBy, partition, sortBy} from 'lodash'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import DropdownMenu, {DropdownButton} from 'web/components/comments/dropdown-menu'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import {
compareBySort,
CompatibilitySort,
CompatibilitySortWidget,
isMatchingSearch,
} from 'web/components/compatibility/sort-widget'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {CompatibleBadge} from 'web/components/widgets/compatible-badge'
import {Input} from 'web/components/widgets/input'
import {Linkify} from 'web/components/widgets/linkify'
import {Pagination} from 'web/components/widgets/pagination'
import {shortenName} from 'web/components/widgets/user-link'
@@ -75,8 +80,6 @@ export function separateQuestionsArray(
return {skippedQuestions, answeredQuestions, otherQuestions}
}
type CompatibilitySort = 'your-important' | 'their-important' | 'disagree' | 'your-unanswered'
export function CompatibilityQuestionsDisplay(props: {
isCurrentUser: boolean
user: User
@@ -118,7 +121,7 @@ export function CompatibilityQuestionsDisplay(props: {
const isLooking = useIsLooking()
const [sort, setSort] = usePersistentInMemoryState<CompatibilitySort>(
!isLooking && !fromProfilePage ? 'their-important' : 'your-important',
!isLooking && !fromProfilePage ? 'their_important' : 'your_important',
`compatibility-sort-${user.id}`,
)
const [searchTerm, setSearchTerm] = useState('')
@@ -131,54 +134,30 @@ export function CompatibilityQuestionsDisplay(props: {
answers.filter((a) => {
const question = compatibilityQuestions.find((q) => q.id === a.question_id)
const comparedAnswer = questionIdToComparedAnswer[a.question_id]
if (question && !isMatchingSearch({...question, answer: a}, searchTerm)) return false
if (sort === 'disagree') {
// Answered and not skipped.
if (!comparedAnswer || comparedAnswer.importance < 0) return false
return !getAnswerCompatibility(a, comparedAnswer)
}
if (sort === 'your-unanswered') {
if (sort === 'your_unanswered') {
// Answered and not skipped.
return !comparedAnswer || comparedAnswer.importance === -1
}
if (searchTerm && question) {
const searchLower = searchTerm.toLowerCase()
const questionMatches = question.question?.toLowerCase().includes(searchLower)
const explanationMatches = a.explanation?.toLowerCase().includes(searchLower)
const answerText =
a.multiple_choice != null && question.multiple_choice_options
? Object.entries(question.multiple_choice_options as Record<string, number>).find(
([, val]) => val === a.multiple_choice,
)?.[0]
: null
const answerMatches = answerText?.toLowerCase().includes(searchLower)
const acceptableAnswersText = a.pref_choices
?.map(
(choice) =>
Object.entries(question.multiple_choice_options as Record<string, number>).find(
([, val]) => val === choice,
)?.[0],
)
.filter(Boolean) as string[] | undefined
const acceptableMatches = acceptableAnswersText?.some((text) =>
text.toLowerCase().includes(searchLower),
)
if (!questionMatches && !explanationMatches && !answerMatches && !acceptableMatches)
return false
}
return true
}),
(a) => {
const comparedAnswer = questionIdToComparedAnswer[a.question_id]
if (sort === 'your-important') {
return comparedAnswer ? -comparedAnswer.importance : 0
} else if (sort === 'their-important') {
return -a.importance
if (sort === 'your_important') {
return compareBySort(comparedAnswer, undefined, sort)
} else if (sort === 'disagree') {
return comparedAnswer ? getScoredAnswerCompatibility(a, comparedAnswer) : Infinity
} else if (sort === 'your-unanswered') {
} else if (sort === 'your_unanswered') {
// Not answered first, then skipped, then answered.
return comparedAnswer ? (comparedAnswer.importance >= 0 ? 2 : 1) : 0
}
const question = compatibilityQuestions.find((q) => q.id === a.question_id)
return compareBySort({...a, ...question}, undefined, sort)
},
// Break ties with their answer importance.
(a) => -a.importance,
@@ -212,28 +191,33 @@ export function CompatibilityQuestionsDisplay(props: {
</Row>
{answeredQuestions.length > 0 && (
<div className="relative mt-3">
<MagnifyingGlassIcon className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
<input
type="text"
placeholder={t('answers.search_placeholder', 'Search prompts...')}
{/*<input*/}
{/* type="text"*/}
{/* placeholder={t('answers.search_placeholder', 'Search prompts...')}*/}
{/* value={searchTerm}*/}
{/* onChange={(e) => {*/}
{/* setSearchTerm(e.target.value)*/}
{/* setPage(0)*/}
{/* }}*/}
{/* className="h-8 pl-7 pr-2 text-sm border border-ink-300 rounded-md bg-canvas-0 focus:outline-none focus:ring-1 focus:ring-primary-500 w-48 transition-all"*/}
{/*/>*/}
<Input
value={searchTerm}
onChange={(e) => {
placeholder={t('answers.search_placeholder', 'Search prompts...')}
className={'w-48 xs:w-64'}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
setPage(0)
}}
className="h-8 pl-7 pr-2 text-sm border border-ink-300 rounded-md bg-canvas-0 focus:outline-none focus:ring-1 focus:ring-primary-500 w-48 transition-all"
/>
</div>
)}
{(!isCurrentUser || fromProfilePage) && (
<CompatibilitySortWidget
className="text-sm sm:flex mt-4"
sort={sort}
setSort={setSort}
user={user}
fromProfilePage={fromProfilePage}
/>
)}
<CompatibilitySortWidget
className="text-sm sm:flex mt-4"
sort={sort}
setSort={setSort}
user={user}
profile={profile}
/>
</Row>
{answeredQuestions.length <= 0 ? (
<span className="text-ink-600 text-sm">
@@ -297,14 +281,16 @@ export function CompatibilityQuestionsDisplay(props: {
)}
</>
)}
{(fromSignup || (otherQuestions.length >= 1 && isCurrentUser && !fromProfilePage)) && (
{isCurrentUser && (
<Row className={'w-full justify-center gap-8'}>
<AnswerCompatibilityQuestionButton
user={user}
otherQuestions={otherQuestions}
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
{(fromSignup || (otherQuestions.length >= 1 && !fromProfilePage)) && (
<AnswerCompatibilityQuestionButton
user={user}
otherQuestions={otherQuestions}
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
)}
<CompatibilityPageButton />
</Row>
)}
@@ -329,57 +315,6 @@ export function CompatibilityQuestionsDisplay(props: {
)
}
function CompatibilitySortWidget(props: {
sort: CompatibilitySort
setSort: (sort: CompatibilitySort) => void
user: User
fromProfilePage: Profile | undefined
className?: string
}) {
const {sort, setSort, user, fromProfilePage, className} = props
const currentUser = useUser()
const t = useT()
const sortToDisplay = {
'your-important': fromProfilePage
? t('answers.sort.important_to_user', 'Important to {name}', {
name: fromProfilePage.user.name,
})
: t('answers.sort.important_to_you', 'Important to you'),
'their-important': t('answers.sort.important_to_them', 'Important to {name}', {
name: user.name,
}),
disagree: t('answers.sort.incompatible', 'Incompatible'),
'your-unanswered': t('answers.sort.unanswered_by_you', 'Unanswered by you'),
}
const shownSorts = buildArray(
'your-important',
'their-important',
'disagree',
(!fromProfilePage || fromProfilePage.user_id === currentUser?.id) && 'your-unanswered',
)
return (
<DropdownMenu
className={className}
items={shownSorts.map((sort) => ({
name: sortToDisplay[sort],
onClick: () => {
setSort(sort)
},
}))}
closeOnClick
buttonClass={'!text-ink-600 !hover:!text-ink-600'}
buttonContent={(open: boolean) => (
<DropdownButton content={sortToDisplay[sort]} open={open} />
)}
menuItemsClass={'bg-canvas-0'}
menuWidth="w-48"
/>
)
}
export function CompatibilityAnswerBlock(props: {
answer?: rowFor<'compatibility_answers'>
yourQuestions: QuestionWithCountType[]

View File

@@ -0,0 +1,132 @@
import {Profile} from 'common/profiles/profile'
import {User} from 'common/user'
import {filterDefined} from 'common/util/array'
import {removeNullOrUndefinedProps} from 'common/util/object'
import DropdownMenu, {DropdownButton, DropdownItem} from 'web/components/comments/dropdown-menu'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
export type CompatibilitySort =
| 'your_important'
| 'their_important'
| 'disagree'
| 'your_unanswered'
| 'random'
| 'community_importance'
| 'most_answered'
| 'newest'
export function CompatibilitySortWidget(props: {
sort: CompatibilitySort
setSort: (sort: CompatibilitySort) => void
user: User
profile?: Profile | undefined
className?: string
ignore?: CompatibilitySort[] | undefined
}) {
const {sort, setSort, user, profile, className} = props
const ignore = props.ignore ?? []
const currentUser = useUser()
const t = useT()
const isCheckingOtherProfile = currentUser && profile && profile.user_id !== currentUser.id
const sortToDisplay = removeNullOrUndefinedProps({
your_important: t('answers.sort.important_to_you', 'Important to you'),
their_important: isCheckingOtherProfile
? t('answers.sort.important_to_them', 'Important to {name}', {
name: user.name,
})
: undefined,
disagree: isCheckingOtherProfile ? t('answers.sort.incompatible', 'Incompatible') : undefined,
your_unanswered: isCheckingOtherProfile
? t('answers.sort.unanswered_by_you', 'Unanswered by you')
: undefined,
random: t('compatibility.sort.random', 'Random'),
community_importance: t('compatibility.sort.importance', 'Important to the community'),
most_answered: t('compatibility.sort.most_answered', 'Most answered'),
newest: t('compatibility.sort.newest', 'Newest'),
}) as Record<CompatibilitySort, string>
return (
<DropdownMenu
className={className}
items={
filterDefined(
Object.entries(sortToDisplay).map(([key, value]) => {
if (ignore.includes(key as CompatibilitySort)) return
return {
name: value as string,
onClick: () => {
setSort(key as CompatibilitySort)
},
}
}),
) as DropdownItem[]
}
closeOnClick
buttonClass={''}
buttonContent={(open: boolean) => (
<DropdownButton content={sortToDisplay[sort]} open={open} />
)}
menuItemsClass={'bg-canvas-0'}
menuWidth="w-56"
/>
)
}
export function compareBySort(a: any, b: any, sort: CompatibilitySort) {
if (sort === 'random') {
return Math.random() - 0.5
} else if (sort === 'community_importance') {
const rateA = (a?.community_importance_score ?? 0) / Math.max(a?.answer_count ?? 1, 1)
const rateB = (b?.community_importance_score ?? 0) / Math.max(b?.answer_count ?? 1, 1)
return rateB - rateA
} else if (sort === 'most_answered') {
return b?.answer_count - a?.answer_count
} else if (sort === 'newest') {
return (
(b?.created_time ? new Date(b?.created_time).getTime() : 0) -
(a?.created_time ? new Date(a?.created_time).getTime() : 0)
)
}
const aImportance = (a?.answer ?? a)?.importance ?? -1
const bImportance = (b?.answer ?? b)?.importance ?? -1
return bImportance - aImportance
}
export function isMatchingSearch(question: any, searchTerm: string) {
console.log(searchTerm, question)
if (searchTerm) {
const searchLower = searchTerm.toLowerCase()
const questionMatches = question.question?.toLowerCase().includes(searchLower)
if (questionMatches) return true
const a = question.answer
if (a) {
const explanationMatches = a?.explanation?.toLowerCase().includes(searchLower)
const answerText =
a.multiple_choice != null && question.multiple_choice_options
? Object.entries(question.multiple_choice_options as Record<string, number>).find(
([, val]) => val === a.multiple_choice,
)?.[0]
: null
const answerMatches = answerText?.toLowerCase().includes(searchLower)
const acceptableAnswersText = a.pref_choices
?.map(
(choice: any) =>
Object.entries(question.multiple_choice_options as Record<string, number>).find(
([, val]) => val === choice,
)?.[0],
)
.filter(Boolean) as string[] | undefined
const acceptableMatches = acceptableAnswersText?.some((text) =>
text.toLowerCase().includes(searchLower),
)
if (explanationMatches || answerMatches || acceptableMatches) return true
}
return false
}
return true
}

View File

@@ -15,7 +15,7 @@ export const Input = forwardRef(
<input
ref={ref}
className={clsx(
'invalid:border-error invalid:text-error disabled:bg-canvas-50 disabled:border-ink-200 disabled:text-ink-500 bg-canvas-0 h-12 rounded-md border px-4 shadow-sm transition-colors invalid:placeholder-rose-700 focus:outline-none focus:ring-1 disabled:cursor-not-allowed md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0',
'invalid:border-error invalid:text-error disabled:bg-canvas-50 disabled:border-ink-200 disabled:text-ink-500 bg-canvas-0 h-12 rounded-xl border px-4 shadow-sm transition-colors invalid:placeholder-rose-700 focus:outline-none focus:ring-1 disabled:cursor-not-allowed md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0',
error
? 'border-error text-error focus:border-error focus:ring-error placeholder-rose-700' // matches invalid: styles
: 'border-ink-300 placeholder-ink-400 focus:ring-primary-500 focus:border-primary-500',

View File

@@ -78,6 +78,7 @@ export const useUserCompatibilityAnswers = (userId: string | undefined) => {
export type QuestionWithCountType = Row<'compatibility_prompts'> & {
answer_count: number
score: number
community_importance_score?: number
}
export const useFRQuestionsWithAnswerCount = () => {
@@ -95,7 +96,7 @@ export const useFRQuestionsWithAnswerCount = () => {
return FRquestionsWithCount as QuestionWithCountType[]
}
export const useCompatibilityQuestionsWithAnswerCount = (keyword?: string) => {
export const useCompatibilityQuestionsWithAnswerCount = () => {
const {locale} = useLocale()
const firebaseUser = useFirebaseUser()
const [compatibilityQuestions, setCompatibilityQuestions] = usePersistentInMemoryState<
@@ -106,7 +107,7 @@ export const useCompatibilityQuestionsWithAnswerCount = (keyword?: string) => {
async function refreshCompatibilityQuestions() {
if (!firebaseUser) return
setIsLoading(true)
return api('get-compatibility-questions', {locale, keyword}).then((res) => {
return api('get-compatibility-questions', {locale}).then((res) => {
setCompatibilityQuestions(res.questions)
setIsLoading(false)
})
@@ -114,7 +115,7 @@ export const useCompatibilityQuestionsWithAnswerCount = (keyword?: string) => {
useEffect(() => {
refreshCompatibilityQuestions()
}, [firebaseUser, locale, keyword])
}, [firebaseUser, locale])
return {
refreshCompatibilityQuestions,

View File

@@ -2,6 +2,14 @@ import {Row, run} from 'common/supabase/utils'
import {db} from 'web/lib/supabase/db'
export type Question = Row<'compatibility_prompts'>
export type Answer = Row<'compatibility_answers_free'>
export type QuestionWithAnswer = Question & {
answer?: Row<'compatibility_answers'>
answer_count: number
score: number
community_importance_score?: number
}
export const getAllQuestions = async () => {
const res = await run(db.from('compatibility_prompts').select('*').order('created_time'))
return res.data

View File

@@ -1,11 +1,17 @@
import clsx from 'clsx'
import {debug} from 'common/logger'
import {Row} from 'common/supabase/utils'
import {User} from 'common/user'
import {debounce} from 'lodash'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {CompatibilityAnswerBlock} from 'web/components/answers/compatibility-questions-display'
import {
compareBySort,
CompatibilitySort,
CompatibilitySortWidget,
isMatchingSearch,
} from 'web/components/compatibility/sort-widget'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {UncontrolledTabs} from 'web/components/layout/tabs'
import {EnglishOnlyWarning} from 'web/components/news/english-only-warning'
import {PageBase} from 'web/components/page-base'
@@ -21,50 +27,49 @@ import {
} from 'web/hooks/use-questions'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {Question} from 'web/lib/supabase/questions'
type QuestionWithAnswer = Question & {
answer?: Row<'compatibility_answers'>
answer_count: number
score: number
}
import {QuestionWithAnswer} from 'web/lib/supabase/questions'
export default function CompatibilityPage() {
const user = useUser()
const isMobile = useIsMobile()
const sep = isMobile ? '\n' : ''
const [keyword, setKeyword] = useState('')
const [debouncedKeyword, setDebouncedKeyword] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const [sort, setSort] = useState<CompatibilitySort>('random')
const searchInputRef = useRef<HTMLInputElement>(null)
const {compatibilityAnswers, refreshCompatibilityAnswers} = useUserCompatibilityAnswers(user?.id)
const {compatibilityQuestions, refreshCompatibilityQuestions, isLoading} =
useCompatibilityQuestionsWithAnswerCount(debouncedKeyword || undefined)
useCompatibilityQuestionsWithAnswerCount()
const t = useT()
// Debounce keyword changes
const debouncedSetKeyword = useMemo(
() => debounce((value: string) => setDebouncedKeyword(value), 500),
() => debounce((value: string) => setDebouncedSearchTerm(value), 500),
[],
)
useEffect(() => {
debouncedSetKeyword(keyword)
debouncedSetKeyword(searchTerm)
// Cleanup debounce on unmount
return () => debouncedSetKeyword.cancel()
}, [keyword, debouncedSetKeyword])
}, [searchTerm, debouncedSetKeyword])
const questionsWithAnswers = useMemo(() => {
if (!compatibilityQuestions) return []
const answerMap = new Map(compatibilityAnswers?.map((a) => [a.question_id, a]) ?? [])
return compatibilityQuestions
const withAnswers = compatibilityQuestions
.map((q) => ({
...q,
answer: answerMap.get(q.id),
}))
.sort((a, b) => a.importance_score - b.importance_score) as QuestionWithAnswer[]
}, [compatibilityQuestions, compatibilityAnswers])
.filter((qna) => isMatchingSearch(qna, debouncedSearchTerm))
return withAnswers.sort((a, b) => {
return compareBySort(a, b, sort)
}) as QuestionWithAnswer[]
}, [compatibilityQuestions, compatibilityAnswers, sort, debouncedSearchTerm])
const {answered, notAnswered, skipped} = useMemo(() => {
const answered: QuestionWithAnswer[] = []
@@ -112,15 +117,23 @@ export default function CompatibilityPage() {
{user ? (
<Col className="w-full p-4">
<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: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value)
}}
/>
<Row className="flex-wrap gap-2 mb-4 items-start sm:items-center">
<Input
ref={searchInputRef}
value={searchTerm}
placeholder={t('compatibility.search_placeholder', 'Search questions and answers...')}
className={'w-full max-w-xs'}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}}
/>
<CompatibilitySortWidget
className="text-sm sm:flex mt-4 mr-4 ml-auto"
sort={sort}
setSort={setSort}
user={user}
/>
</Row>
<EnglishOnlyWarning />
<UncontrolledTabs
trackingName={'compatibility page'}
@@ -135,7 +148,7 @@ export default function CompatibilityPage() {
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
keyword={keyword}
searchTerm={searchTerm}
/>
),
},
@@ -148,7 +161,7 @@ export default function CompatibilityPage() {
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
keyword={keyword}
searchTerm={searchTerm}
/>
),
},
@@ -161,7 +174,7 @@ export default function CompatibilityPage() {
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
keyword={keyword}
searchTerm={searchTerm}
/>
),
},
@@ -188,14 +201,14 @@ function QuestionList({
isLoading,
user,
refreshCompatibilityAll,
keyword,
searchTerm,
}: {
questions: QuestionWithAnswer[]
status: 'answered' | 'not-answered' | 'skipped'
isLoading: boolean
user: User
refreshCompatibilityAll: () => void
keyword: string
searchTerm: string
}) {
const t = useT()
const BATCH_SIZE = 100
@@ -203,7 +216,7 @@ function QuestionList({
// Reset pagination when the questions list changes (e.g., switching tabs or refreshed data)
useEffect(() => {
debug('resetting pagination')
// debug('resetting pagination')
setVisibleCount(BATCH_SIZE)
}, [questions])
@@ -223,9 +236,9 @@ function QuestionList({
if (!isLoading && questions.length === 0) {
return (
<div className="text-ink-500 p-4">
{keyword ? (
{searchTerm ? (
t('compatibility.empty.no_results', 'No results for "{keyword}"', {
keyword,
keyword: searchTerm,
})
) : (
<>