mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-24 17:41:27 -04:00
Sort compat prompts by community importance and answer count
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[]
|
||||
|
||||
132
web/components/compatibility/sort-widget.tsx
Normal file
132
web/components/compatibility/sort-widget.tsx
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user