このコミットが含まれているのは:
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
@@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { buildGekanatorQuestions,
|
||||
expectedAnswerForQuestion,
|
||||
fetchGekanatorExtraQuestions,
|
||||
fetchGekanatorQuestions,
|
||||
fetchGekanatorPosts,
|
||||
restoreGekanatorQuestion,
|
||||
saveGekanatorExtraQuestionAnswers,
|
||||
saveGekanatorGame,
|
||||
saveGekanatorQuestionSuggestion,
|
||||
storeGekanatorQuestion } from '@/lib/gekanator'
|
||||
@@ -19,6 +22,7 @@ import type { FC } from 'react'
|
||||
|
||||
import type { GekanatorAnswerLog,
|
||||
GekanatorAnswerValue,
|
||||
GekanatorExtraQuestion,
|
||||
GekanatorQuestion,
|
||||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||||
import type { Post } from '@/types'
|
||||
@@ -31,6 +35,7 @@ type Phase =
|
||||
| 'end'
|
||||
| 'review'
|
||||
| 'question_suggestion'
|
||||
| 'extra_questions'
|
||||
| 'learned'
|
||||
|
||||
type AnswerOption = {
|
||||
@@ -87,7 +92,10 @@ type StoredGekanatorGame = {
|
||||
gameSeed?: string
|
||||
questionSuggestion: string
|
||||
questionSuggestionAnswer: GekanatorAnswerValue
|
||||
questionSuggestionCount?: number }
|
||||
questionSuggestionCount?: number
|
||||
extraQuestions?: GekanatorExtraQuestion[]
|
||||
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
|
||||
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
|
||||
|
||||
const answerOptions: AnswerOption[] = [
|
||||
{ label: 'はい', value: 'yes' },
|
||||
@@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
|
||||
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
|
||||
|
||||
|
||||
const resettableExtraQuestionState = (): {
|
||||
extraQuestions: GekanatorExtraQuestion[]
|
||||
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
|
||||
extraQuestionState: 'idle'
|
||||
} => ({
|
||||
extraQuestions: [],
|
||||
extraQuestionAnswers: { },
|
||||
extraQuestionState: 'idle' })
|
||||
|
||||
|
||||
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||
switch (answer)
|
||||
{
|
||||
@@ -236,6 +254,55 @@ const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||
}
|
||||
|
||||
|
||||
const answerScalarFor = (
|
||||
answer: GekanatorAnswerValue | null,
|
||||
): number | null => {
|
||||
switch (answer)
|
||||
{
|
||||
case 'yes':
|
||||
return 1
|
||||
case 'partial':
|
||||
return .5
|
||||
case 'probably_no':
|
||||
return -.5
|
||||
case 'no':
|
||||
return -1
|
||||
case 'unknown':
|
||||
case null:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const deltaForExpectedAnswer = (
|
||||
expected: GekanatorAnswerValue | null,
|
||||
answer: GekanatorAnswerValue,
|
||||
): number => {
|
||||
if (answer === 'unknown' || expected === null || expected === 'unknown')
|
||||
return 0
|
||||
|
||||
if (expected === 'yes' || expected === 'no')
|
||||
return deltaFor (expected === 'yes', answer)
|
||||
|
||||
const expectedScalar = answerScalarFor (expected)
|
||||
const answerScalar = answerScalarFor (answer)
|
||||
if (expectedScalar === null || answerScalar === null)
|
||||
return 0
|
||||
|
||||
const distance = Math.abs (expectedScalar - answerScalar)
|
||||
if (distance >= 2)
|
||||
return -4
|
||||
if (distance >= 1.5)
|
||||
return -2
|
||||
if (distance >= 1)
|
||||
return 0
|
||||
if (distance >= .5)
|
||||
return 2
|
||||
|
||||
return 4
|
||||
}
|
||||
|
||||
|
||||
const answerWeightFor = (
|
||||
questionId: string,
|
||||
softenedQuestionIds: Set<string>,
|
||||
@@ -277,10 +344,11 @@ const recalculateScores = ({
|
||||
|
||||
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||||
posts.forEach (post => {
|
||||
const expected = expectedAnswerForQuestion (question, post)
|
||||
nextScores.set (
|
||||
post.id,
|
||||
(nextScores.get (post.id) ?? 0)
|
||||
+ deltaFor (question.test (post), answer.answer) * weight)
|
||||
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -318,9 +386,10 @@ const candidatePostsFor = ({
|
||||
switch (answer.answer)
|
||||
{
|
||||
case 'yes':
|
||||
return question.test (post)
|
||||
case 'no':
|
||||
return !(question.test (post))
|
||||
case 'no': {
|
||||
const expected = expectedAnswerForQuestion (question, post)
|
||||
return expected === null || expected === 'unknown' || expected === answer.answer
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -378,20 +447,19 @@ const previewAnswer = ({
|
||||
answer: GekanatorAnswerValue
|
||||
}): AnswerPreview => {
|
||||
const hardFilteredPosts =
|
||||
answer === 'yes'
|
||||
? posts.filter (post => question.test (post))
|
||||
: answer === 'no'
|
||||
? posts.filter (post => !(question.test (post)))
|
||||
: posts
|
||||
answer === 'unknown'
|
||||
? posts
|
||||
: posts.filter (post => expectedAnswerForQuestion (question, post) === answer)
|
||||
const nextPosts =
|
||||
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
|
||||
answer !== 'unknown' && hardFilteredPosts.length > 0
|
||||
? hardFilteredPosts
|
||||
: posts
|
||||
const nextScores = new Map (scores)
|
||||
nextPosts.forEach (post => {
|
||||
const expected = expectedAnswerForQuestion (question, post)
|
||||
nextScores.set (
|
||||
post.id,
|
||||
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
|
||||
(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
|
||||
})
|
||||
|
||||
const confidences = confidencesFor (nextPosts, nextScores)
|
||||
@@ -497,6 +565,8 @@ const sameConditionValue = (
|
||||
return String (condition.length)
|
||||
case 'title-has-ascii':
|
||||
return ''
|
||||
case 'post-similarity':
|
||||
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
||||
const expectedAnswerFor = (
|
||||
question: GekanatorQuestion | undefined,
|
||||
correctPost: Post | null,
|
||||
): GekanatorAnswerValue | null => {
|
||||
if (!(question) || !(correctPost))
|
||||
return null
|
||||
|
||||
return question.test (correctPost) ? 'yes' : 'no'
|
||||
}
|
||||
): GekanatorAnswerValue | null =>
|
||||
expectedAnswerForQuestion (question, correctPost)
|
||||
|
||||
|
||||
const GekanatorPage: FC = () => {
|
||||
const storedGame = useMemo (loadStoredGame, [])
|
||||
const queryClient = useQueryClient ()
|
||||
const [gameSeed, setGameSeed] = useState (
|
||||
storedGame?.gameSeed ?? createGameSeed ())
|
||||
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||||
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
|
||||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||||
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||||
storedGame?.questionSuggestionCount ?? 0)
|
||||
const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
|
||||
storedGame?.extraQuestions ?? [])
|
||||
const [extraQuestionAnswers, setExtraQuestionAnswers] =
|
||||
useState<Record<string, GekanatorAnswerValue>> (
|
||||
storedGame?.extraQuestionAnswers ?? { })
|
||||
const [extraQuestionState, setExtraQuestionState] = useState<
|
||||
'idle' | 'loading' | 'ready' | 'empty' | 'saved'
|
||||
> (storedGame?.extraQuestionState ?? 'idle')
|
||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||
|
||||
const { data: posts = [], isLoading, error } = useQuery ({
|
||||
@@ -876,7 +951,10 @@ const GekanatorPage: FC = () => {
|
||||
gameSeed,
|
||||
questionSuggestion,
|
||||
questionSuggestionAnswer,
|
||||
questionSuggestionCount }
|
||||
questionSuggestionCount,
|
||||
extraQuestions,
|
||||
extraQuestionAnswers,
|
||||
extraQuestionState }
|
||||
|
||||
try
|
||||
{
|
||||
@@ -908,7 +986,10 @@ const GekanatorPage: FC = () => {
|
||||
gameSeed,
|
||||
questionSuggestion,
|
||||
questionSuggestionAnswer,
|
||||
questionSuggestionCount])
|
||||
questionSuggestionCount,
|
||||
extraQuestions,
|
||||
extraQuestionAnswers,
|
||||
extraQuestionState])
|
||||
|
||||
const eligiblePosts = useMemo (
|
||||
() => candidatePostsFor ({
|
||||
@@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => {
|
||||
setQuestionSuggestion ('')
|
||||
setQuestionSuggestionAnswer ('yes')
|
||||
}})
|
||||
const extraQuestionAnswersMutation = useMutation ({
|
||||
mutationFn: saveGekanatorExtraQuestionAnswers,
|
||||
onSuccess: () => {
|
||||
setExtraQuestionState ('saved')
|
||||
setPhase ('learned')
|
||||
}})
|
||||
|
||||
const resetExtraQuestionState = () => {
|
||||
const next = resettableExtraQuestionState ()
|
||||
setExtraQuestions (next.extraQuestions)
|
||||
setExtraQuestionAnswers (next.extraQuestionAnswers)
|
||||
setExtraQuestionState (next.extraQuestionState)
|
||||
extraQuestionAnswersMutation.reset ()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
clearStoredGame ()
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
setPhase ('intro')
|
||||
setScores (new Map ())
|
||||
setAnswers ([])
|
||||
@@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => {
|
||||
setQuestionSuggestion ('')
|
||||
setQuestionSuggestionAnswer ('yes')
|
||||
setQuestionSuggestionCount (0)
|
||||
resetExtraQuestionState ()
|
||||
setHistory ([])
|
||||
}
|
||||
|
||||
@@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => {
|
||||
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
resetExtraQuestionState ()
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setReviewGuessedPostId (guessedPostId)
|
||||
@@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => {
|
||||
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
resetExtraQuestionState ()
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setSelectingCorrectPost (false)
|
||||
@@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => {
|
||||
}
|
||||
|
||||
const saveAndLearn = () => {
|
||||
resetExtraQuestionState ()
|
||||
saveReviewedResult (() => setPhase ('learned'))
|
||||
}
|
||||
|
||||
@@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => {
|
||||
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
resetExtraQuestionState ()
|
||||
setAnswers (answers.map ((answer, i) =>
|
||||
i === index ? { ...answer, answer: value } : answer))
|
||||
}
|
||||
@@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => {
|
||||
{
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
resetExtraQuestionState ()
|
||||
setReviewCorrectPostId (post.id)
|
||||
setSelectingCorrectPost (false)
|
||||
setSearch ('')
|
||||
@@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => {
|
||||
})
|
||||
.slice (0, 20)
|
||||
|
||||
const loadExtraQuestions = async (gameId: number) => {
|
||||
extraQuestionAnswersMutation.reset ()
|
||||
setExtraQuestionState ('loading')
|
||||
setExtraQuestions ([])
|
||||
setExtraQuestionAnswers ({ })
|
||||
setPhase ('extra_questions')
|
||||
|
||||
try
|
||||
{
|
||||
const questions = await queryClient.fetchQuery ({
|
||||
queryKey: gekanatorKeys.extraQuestions (gameId),
|
||||
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
|
||||
setExtraQuestions (questions)
|
||||
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
|
||||
}
|
||||
catch
|
||||
{
|
||||
setExtraQuestionState ('empty')
|
||||
}
|
||||
}
|
||||
|
||||
const startExtraQuestions = () => {
|
||||
if (reviewCorrectPostId === null || saveMutation.isPending)
|
||||
return
|
||||
|
||||
saveReviewedResult (gameId => {
|
||||
void loadExtraQuestions (gameId)
|
||||
})
|
||||
}
|
||||
|
||||
const answerExtraQuestion = (
|
||||
questionId: number,
|
||||
value: GekanatorAnswerValue,
|
||||
) => {
|
||||
setExtraQuestionAnswers ({
|
||||
...extraQuestionAnswers,
|
||||
[String (questionId)]: value })
|
||||
}
|
||||
|
||||
const saveExtraQuestions = () => {
|
||||
if (
|
||||
savedGameId === null
|
||||
|| extraQuestionAnswersMutation.isPending
|
||||
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
|
||||
)
|
||||
return
|
||||
|
||||
extraQuestionAnswersMutation.mutate ({
|
||||
gameId: savedGameId,
|
||||
answers: extraQuestions.map (question => ({
|
||||
questionId: question.id,
|
||||
answer: extraQuestionAnswers[String (question.id)] })) })
|
||||
}
|
||||
|
||||
const dialogue =
|
||||
phase === 'learned' && resultWon
|
||||
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||||
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
|
||||
onClick={() => setPhase ('question_suggestion')}>
|
||||
質問を追加
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-yellow-300 px-4 py-2
|
||||
hover:bg-yellow-100 dark:border-red-700
|
||||
dark:hover:bg-red-900 disabled:opacity-50"
|
||||
disabled={reviewCorrectPostId === null
|
||||
|| saveMutation.isPending
|
||||
|| extraQuestionState === 'loading'
|
||||
|| extraQuestionAnswersMutation.isPending}
|
||||
onClick={startExtraQuestions}>
|
||||
追加で質問に答へる
|
||||
</button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
|
||||
</p>)}
|
||||
</div>)}
|
||||
|
||||
{phase === 'extra_questions' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">追加学習</p>
|
||||
<p className="text-xl font-bold">追加で 2 問まで答へてください。</p>
|
||||
</div>
|
||||
|
||||
{extraQuestionState === 'loading' && (
|
||||
<p>追加質問を読み込んでゐます...</p>)}
|
||||
|
||||
{extraQuestionState === 'empty' && (
|
||||
<p>追加で学習できる質問はありませんでした。</p>)}
|
||||
|
||||
{extraQuestionState === 'ready' && (
|
||||
<div className="space-y-3">
|
||||
{extraQuestions.map ((question, index) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className="rounded border border-yellow-100 p-3
|
||||
dark:border-red-900">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
追加質問 {index + 1}
|
||||
</div>
|
||||
<div className="font-bold">{question.text}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{answerOptions.map (option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn (
|
||||
'rounded border px-3 py-2',
|
||||
extraQuestionAnswers[String (question.id)] === option.value
|
||||
? 'border-pink-600 bg-pink-600 text-white'
|
||||
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
|
||||
onClick={() => answerExtraQuestion (question.id, option.value)}>
|
||||
{option.label}
|
||||
</button>))}
|
||||
</div>
|
||||
</div>))}
|
||||
</div>)}
|
||||
|
||||
{extraQuestionAnswersMutation.isError && (
|
||||
<p className="text-sm text-red-600">
|
||||
学習内容を保存できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-neutral-300 px-4 py-2
|
||||
hover:bg-neutral-100 dark:border-neutral-700
|
||||
dark:hover:bg-red-900"
|
||||
disabled={extraQuestionAnswersMutation.isPending}
|
||||
onClick={() => setPhase ('end')}>
|
||||
戻る
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
hover:bg-pink-500 disabled:opacity-50"
|
||||
disabled={
|
||||
extraQuestionState !== 'ready'
|
||||
|| extraQuestionAnswersMutation.isPending
|
||||
|| extraQuestions.some (
|
||||
question => !(extraQuestionAnswers[String (question.id)]))
|
||||
}
|
||||
onClick={saveExtraQuestions}>
|
||||
学習する
|
||||
</button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{phase === 'learned' && (
|
||||
<div className="space-y-3">
|
||||
<p>覚えたよ.次はもっと見通す.</p>
|
||||
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
|
||||
新しい課題から参照
ユーザをブロックする