From 24d2fe9c3244e3f91cfc0a552d610d23f1987398 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 9 Mar 2026 02:27:10 +0100 Subject: [PATCH] Sort compat prompts by community importance and answer count --- .../api/src/delete-compatibility-answer.ts | 8 +- .../api/src/get-compatibililty-questions.ts | 26 +-- backend/api/src/set-compatibility-answer.ts | 4 + backend/supabase/migration.sql | 1 + .../20260308_add_importance_counts.sql | 42 +++++ common/messages/de.json | 4 + common/messages/fr.json | 4 + common/src/api/schema.ts | 1 + common/src/supabase/schema.ts | 6 + common/src/util/array.ts | 9 + .../answer-compatibility-question-content.tsx | 25 ++- .../compatibility-questions-display.tsx | 157 +++++------------- web/components/compatibility/sort-widget.tsx | 132 +++++++++++++++ web/components/widgets/input.tsx | 2 +- web/hooks/use-questions.ts | 7 +- web/lib/supabase/questions.ts | 8 + web/pages/compatibility.tsx | 81 +++++---- 17 files changed, 338 insertions(+), 179 deletions(-) create mode 100644 backend/supabase/migrations/20260308_add_importance_counts.sql create mode 100644 web/components/compatibility/sort-widget.tsx diff --git a/backend/api/src/delete-compatibility-answer.ts b/backend/api/src/delete-compatibility-answer.ts index 0501d897..2cd89a3b 100644 --- a/backend/api/src/delete-compatibility-answer.ts +++ b/backend/api/src/delete-compatibility-answer.ts @@ -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, } } diff --git a/backend/api/src/get-compatibililty-questions.ts b/backend/api/src/get-compatibililty-questions.ts index bbcec3d1..d469a185 100644 --- a/backend/api/src/get-compatibililty-questions.ts +++ b/backend/api/src/get-compatibililty-questions.ts @@ -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(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 & {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', diff --git a/backend/api/src/set-compatibility-answer.ts b/backend/api/src/set-compatibility-answer.ts index 8a1b37eb..487d34e5 100644 --- a/backend/api/src/set-compatibility-answer.ts +++ b/backend/api/src/set-compatibility-answer.ts @@ -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) } diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 50957536..8cf95e0f 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -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; diff --git a/backend/supabase/migrations/20260308_add_importance_counts.sql b/backend/supabase/migrations/20260308_add_importance_counts.sql new file mode 100644 index 00000000..50f9bec6 --- /dev/null +++ b/backend/supabase/migrations/20260308_add_importance_counts.sql @@ -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); diff --git a/common/messages/de.json b/common/messages/de.json index bf41697a..91673c0b 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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 ", diff --git a/common/messages/fr.json b/common/messages/fr.json index 94d1e483..39448870 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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 ", diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 31a53cc1..97416e77 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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', diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index f31e2af6..fcb91376 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -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: [ { diff --git a/common/src/util/array.ts b/common/src/util/array.ts index b3d3041a..beb57ee0 100644 --- a/common/src/util/array.ts +++ b/common/src/util/array.ts @@ -47,3 +47,12 @@ export function fallbackIfEmpty(array: T[], fallback: any) { if (!Array.isArray(array)) return fallback return array.length > 0 ? array : fallback } + +export function shuffle(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 +} diff --git a/web/components/answers/answer-compatibility-question-content.tsx b/web/components/answers/answer-compatibility-question-content.tsx index 75a5d20d..aa089984 100644 --- a/web/components/answers/answer-compatibility-question-content.tsx +++ b/web/components/answers/answer-compatibility-question-content.tsx @@ -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('random') const [loading, setLoading] = useState(false) const [skipLoading, setSkipLoading] = useState(false) @@ -160,9 +162,8 @@ export function AnswerCompatibilityQuestionContent(props: { )} -
{compatibilityQuestion.question}
- {shortenedPopularity && ( - + + {shortenedPopularity && ( - {shortenedPopularity} + + {shortenedPopularity} + + - - - )} + )} + + +
{compatibilityQuestion.question}
diff --git a/web/components/answers/compatibility-questions-display.tsx b/web/components/answers/compatibility-questions-display.tsx index 3d86d861..7f78ab15 100644 --- a/web/components/answers/compatibility-questions-display.tsx +++ b/web/components/answers/compatibility-questions-display.tsx @@ -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( - !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).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).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: { {answeredQuestions.length > 0 && (
- - {*/} + {/* 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"*/} + {/*/>*/} + { + placeholder={t('answers.search_placeholder', 'Search prompts...')} + className={'w-48 xs:w-64'} + onChange={(e: React.ChangeEvent) => { 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" />
)} - {(!isCurrentUser || fromProfilePage) && ( - - )} + {answeredQuestions.length <= 0 ? ( @@ -297,14 +281,16 @@ export function CompatibilityQuestionsDisplay(props: { )} )} - {(fromSignup || (otherQuestions.length >= 1 && isCurrentUser && !fromProfilePage)) && ( + {isCurrentUser && ( - + {(fromSignup || (otherQuestions.length >= 1 && !fromProfilePage)) && ( + + )} )} @@ -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 ( - ({ - name: sortToDisplay[sort], - onClick: () => { - setSort(sort) - }, - }))} - closeOnClick - buttonClass={'!text-ink-600 !hover:!text-ink-600'} - buttonContent={(open: boolean) => ( - - )} - menuItemsClass={'bg-canvas-0'} - menuWidth="w-48" - /> - ) -} - export function CompatibilityAnswerBlock(props: { answer?: rowFor<'compatibility_answers'> yourQuestions: QuestionWithCountType[] diff --git a/web/components/compatibility/sort-widget.tsx b/web/components/compatibility/sort-widget.tsx new file mode 100644 index 00000000..06655e1b --- /dev/null +++ b/web/components/compatibility/sort-widget.tsx @@ -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 + + return ( + { + if (ignore.includes(key as CompatibilitySort)) return + return { + name: value as string, + onClick: () => { + setSort(key as CompatibilitySort) + }, + } + }), + ) as DropdownItem[] + } + closeOnClick + buttonClass={''} + buttonContent={(open: boolean) => ( + + )} + 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).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).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 +} diff --git a/web/components/widgets/input.tsx b/web/components/widgets/input.tsx index ad5ff0ca..0725dc51 100644 --- a/web/components/widgets/input.tsx +++ b/web/components/widgets/input.tsx @@ -15,7 +15,7 @@ export const Input = forwardRef( { 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, diff --git a/web/lib/supabase/questions.ts b/web/lib/supabase/questions.ts index 47afcd8a..f0d13b53 100644 --- a/web/lib/supabase/questions.ts +++ b/web/lib/supabase/questions.ts @@ -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 diff --git a/web/pages/compatibility.tsx b/web/pages/compatibility.tsx index e741757a..3c9ad910 100644 --- a/web/pages/compatibility.tsx +++ b/web/pages/compatibility.tsx @@ -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('random') const searchInputRef = useRef(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 ? ( {t('compatibility.title', 'Your Compatibility Questions')} - ) => { - setKeyword(e.target.value) - }} - /> + + ) => { + setSearchTerm(e.target.value) + }} + /> + + ), }, @@ -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 (
- {keyword ? ( + {searchTerm ? ( t('compatibility.empty.no_results', 'No results for "{keyword}"', { - keyword, + keyword: searchTerm, }) ) : ( <>