ファイル
btrc-hub/frontend/src/pages/GekanatorPage.tsx
T
2026-06-11 23:21:44 +09:00

2234 行
67 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
fetchGekanatorPosts,
normalizeTitleLengthCondition,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion,
titleLengthMinimumForCondition } from '@/lib/gekanator'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorExtraQuestion,
GekanatorQuestionCondition,
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
type Phase =
| 'intro'
| 'question'
| 'guess'
| 'continue'
| 'end'
| 'review'
| 'question_suggestion'
| 'extra_questions'
| 'learned'
type AnswerOption = {
label: string
value: GekanatorAnswerValue }
type Confidence = {
post: Post
score: number
percent: number }
type AnswerPreview = {
answer: GekanatorAnswerValue
top: Confidence | null
candidateCount: number
effectiveCandidates: number
entropy: number }
type GameSnapshot = {
phase: Phase
scores: Map<number, number>
answers: GekanatorAnswerLog[]
askedIds: Set<string>
softenedQuestionIds: Set<string>
askedQuestionBank: GekanatorQuestion[]
search: string
selectingCorrectPost: boolean
rejectedPostIds: Set<number>
lastGuessQuestionCount: number
lastRejectedGuessId: number | null
activeGuessId: number | null
reviewGuessedPostId: number | null
reviewCorrectPostId: number | null }
type StoredGekanatorGame = {
phase: Phase
scores: [number, number][]
answers: GekanatorAnswerLog[]
askedIds: string[]
softenedQuestionIds: string[]
askedQuestionBank?: StoredGekanatorQuestion[]
askedQuestionBankIds?: string[]
search: string
selectingCorrectPost: boolean
saved: boolean
resultWon: boolean | null
rejectedPostIds: number[]
lastGuessQuestionCount: number
lastRejectedGuessId: number | null
activeGuessId: number | null
reviewGuessedPostId: number | null
reviewCorrectPostId: number | null
savedGameId: number | null
gameSeed?: string
questionSuggestion: string
questionSuggestionAnswer: GekanatorAnswerValue
questionSuggestionCount?: number
extraQuestions?: GekanatorExtraQuestion[]
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
{ label: 'いいえ', value: 'no' },
{ label: '部分的にそう', value: 'partial' },
{ label: 'たぶんいいえ', value: 'probably_no' },
{ label: 'わからない', value: 'unknown' }]
const answerLabelFor = (value: GekanatorAnswerValue): string =>
answerOptions.find (option => option.value === value)?.label ?? value
const questionsBetweenGuesses = 25
const minQuestionsBeforeCertainGuess = 5
const certainGuessPercent = 99.5
const runnerUpMaxPercent = .5
const hardMaxQuestions = 80
const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const maxQuestionSuggestionsPerGame = 3
const sourcePriorityOffset = (question: GekanatorQuestion): number => {
switch (question.source)
{
case 'user_suggested':
return -1.2
case 'admin_curated':
return -0.8
case 'ai_generated':
return -0.6
default:
return 0
}
}
const priorityWeightOffset = (question: GekanatorQuestion): number =>
(Math.min (3, Math.max (.2, question.priorityWeight)) - 1) * -.8
const createGameSeed = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
return crypto.randomUUID ()
return `${ Date.now () }:${ Math.random ().toString (36).slice (2) }`
}
const normalizeStoredQuestionId = (
questionId: string,
condition?: GekanatorQuestionCondition,
): string => {
if (condition?.type === 'title-length-greater-than')
return `title:length-at-least:${ condition.length + 1 }`
if (questionId.startsWith ('title:length-greater-than:'))
{
const length = Number (questionId.split (':').pop ())
if (Number.isInteger (length))
return `title:length-at-least:${ length + 1 }`
}
return questionId
}
const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({
...game,
answers: game.answers.map (answer => ({
...answer,
questionId: normalizeStoredQuestionId (
answer.questionId,
answer.questionCondition),
questionCondition: answer.questionCondition
? normalizeTitleLengthCondition (answer.questionCondition)
: undefined })),
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
softenedQuestionIds: game.softenedQuestionIds.map (questionId =>
normalizeStoredQuestionId (questionId)),
askedQuestionBank: game.askedQuestionBank?.map (question =>
({
...question,
id: normalizeStoredQuestionId (question.id, question.condition),
condition: normalizeTitleLengthCondition (question.condition) })),
askedQuestionBankIds: game.askedQuestionBankIds?.map (questionId =>
normalizeStoredQuestionId (questionId)) })
const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
switch (question.source)
{
case 'user_suggested':
return 3
case 'admin_curated':
return 3
case 'ai_generated':
return 3
default:
return 1
}
}
const shouldReplaceMergedQuestion = (
current: GekanatorQuestion | undefined,
candidate: GekanatorQuestion,
): boolean => {
if (!(current))
return true
const currentSourcePriority = sourcePriorityForMerge (current)
const candidateSourcePriority = sourcePriorityForMerge (candidate)
if (candidateSourcePriority !== currentSourcePriority)
return candidateSourcePriority > currentSourcePriority
if (candidate.priorityWeight !== current.priorityWeight)
return candidate.priorityWeight > current.priorityWeight
return true
}
const hashString = (value: string): number => {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1)
{
hash ^= value.charCodeAt (i)
hash = Math.imul (hash, 16777619)
}
return hash >>> 0
}
const deterministicUnitFloat = (seed: string): number =>
hashString (seed) / 4294967295
const clearStoredGame = (): void => {
try
{
sessionStorage.removeItem (gameStorageKey)
}
catch
{
return
}
}
const loadStoredGame = (): StoredGekanatorGame | null => {
try
{
const raw = sessionStorage.getItem (gameStorageKey)
if (!(raw))
return null
return normalizeStoredGame (JSON.parse (raw) as StoredGekanatorGame)
}
catch
{
clearStoredGame ()
return 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)
{
case 'yes':
return matched ? 4 : -4
case 'no':
return matched ? -4 : 4
case 'partial':
return matched ? 2 : -1
case 'probably_no':
return matched ? -2 : 2
case 'unknown':
return 0
}
}
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>,
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
const questionDifficulty = (question: GekanatorQuestion): number => {
if (question.kind === 'source')
return 4
if (question.kind === 'original_date')
return 4
if (question.kind === 'title')
return 4
if (question.kind === 'tag')
return 3
return 1
}
const recalculateScores = ({
posts,
questions,
answers,
softenedQuestionIds,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
}): Map<number, number> => {
const questionById = new Map (questions.map (question => [question.id, question]))
const nextScores = new Map<number, number> ()
answers.forEach (answer => {
const question = questionById.get (answer.questionId)
if (!(question))
return
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
posts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0)
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
})
})
return nextScores
}
const candidatePostsFor = ({
posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
}): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
return answers.every (answer => {
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
switch (answer.answer)
{
case 'yes':
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
})
})
}
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
if (posts.length === 0)
return []
const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
const maxScore = Math.max (...raw.map (({ score }) => score))
const weighted = raw.map (item => ({
...item,
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1
return weighted
.map (({ post, score, weight }) => ({
post,
score,
percent: weight / total * 100 }))
.sort ((a, b) => b.percent - a.percent)
}
const entropyFor = (confidences: Confidence[]): number =>
confidences.reduce ((sum, item) => {
const p = item.percent / 100
return p > 0 ? sum - p * Math.log2 (p) : sum
}, 0)
const effectiveCandidatesFor = (confidences: Confidence[]): number => {
const concentration = confidences.reduce ((sum, item) => {
const p = item.percent / 100
return sum + p * p
}, 0)
return concentration > 0 ? 1 / concentration : 0
}
const previewAnswer = ({
posts,
scores,
question,
answer,
}: {
posts: Post[]
scores: Map<number, number>
question: GekanatorQuestion
answer: GekanatorAnswerValue
}): AnswerPreview => {
const hardFilteredPosts =
answer === 'unknown'
? posts
: posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer
})
const nextPosts =
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) + deltaForExpectedAnswer (expected, answer))
})
const confidences = confidencesFor (nextPosts, nextScores)
return {
answer,
top: confidences[0] ?? null,
candidateCount: nextPosts.length,
effectiveCandidates: effectiveCandidatesFor (confidences),
entropy: entropyFor (confidences) }
}
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
const byId = new Map<string, GekanatorQuestion> ()
questions.forEach (question => {
const current = byId.get (question.id)
if (shouldReplaceMergedQuestion (current, question))
byId.set (question.id, question)
})
return [...byId.values ()]
}
const softenNextQuestionIds = ({
questions,
answers,
softenedQuestionIds,
}: {
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
}): Set<string> | null => {
const questionById = new Map (questions.map (question => [question.id, question]))
const candidate = [...answers]
.reverse ()
.map (answer => {
const question = questionById.get (answer.questionId)
return { answer, question }
})
.filter ((item): item is {
answer: GekanatorAnswerLog
question: GekanatorQuestion } =>
item.question !== undefined
&& item.answer.answer !== 'unknown'
&& !(softenedQuestionIds.has (item.answer.questionId)))
.sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0]
if (!(candidate))
return null
return new Set ([...softenedQuestionIds, candidate.answer.questionId])
}
type ExclusiveConditionGroup =
| 'original-month'
| 'original-year'
| 'original-month-day'
| 'source'
const exclusiveConditionGroupFor = (
condition: GekanatorQuestion['condition'],
): ExclusiveConditionGroup | null => {
switch (condition.type)
{
case 'original-month':
return 'original-month'
case 'original-year':
return 'original-year'
case 'original-month-day':
return 'original-month-day'
case 'source':
return 'source'
default:
return null
}
}
const sameConditionValue = (
left: GekanatorQuestion['condition'],
right: GekanatorQuestion['condition'],
): boolean => {
const leftTitleLength = titleLengthMinimumForCondition (left)
const rightTitleLength = titleLengthMinimumForCondition (right)
if (leftTitleLength !== null || rightTitleLength !== null)
return leftTitleLength !== null
&& rightTitleLength !== null
&& leftTitleLength === rightTitleLength
if (left.type !== right.type)
return false
const valueKeyFor = (condition: GekanatorQuestion['condition']): string => {
switch (condition.type)
{
case 'tag':
return condition.key
case 'source':
return condition.host
case 'original-year':
return String (condition.year)
case 'original-month':
return String (condition.month)
case 'original-month-day':
return condition.monthDay
case 'title-has-ascii':
return ''
case 'post-similarity':
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
case 'title-length-at-least':
case 'title-length-greater-than':
return String (titleLengthMinimumForCondition (condition) ?? '')
}
}
return valueKeyFor (left) === valueKeyFor (right)
}
const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
if (condition.type === 'original-month')
return condition.month
if (condition.type !== 'original-month-day')
return null
const month = Number (condition.monthDay.split ('-')[0])
return Number.isInteger (month) ? month : null
}
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
return false
switch (answer)
{
case 'yes':
return candidateLength <= previousLength
case 'no':
return candidateLength >= previousLength
default:
return false
}
}
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
})
const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous)
if (candidateMonth === null || previousMonth === null)
return false
const sameType = candidate.type === previous.type
if (sameType)
return false
return candidateMonth === previousMonth
}
const isExclusiveContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
const candidateGroup = exclusiveConditionGroupFor (candidate)
const previousGroup = exclusiveConditionGroupFor (previous)
if (candidateGroup !== null && candidateGroup === previousGroup)
return !(sameConditionValue (candidate, previous))
const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous)
if (candidateMonth !== null && previousMonth !== null)
return candidateMonth !== previousMonth
return false
}
const contradictionPenaltyFor = ({
question,
answers,
}: {
question: GekanatorQuestion
answers: GekanatorAnswerLog[]
}): number => {
return answers.reduce ((sum, answer) => {
const previous = answer.questionCondition
if (!(previous))
return sum
switch (answer.answer)
{
case 'yes':
return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0)
case 'partial':
return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
case 'no':
return sum + (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
? 40
: 0)
case 'probably_no':
return sum + (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
? 20
: 0)
default:
return sum
}
}, 0)
}
const chooseQuestion = ({
posts,
questions,
scores,
answers,
askedIds,
gameSeed,
}: {
posts: Post[]
questions: GekanatorQuestion[]
scores: Map<number, number>
answers: GekanatorAnswerLog[]
askedIds: Set<string>
gameSeed: string
}): GekanatorQuestion | null => {
const scoredPosts = posts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
.sort ((a, b) => b.score - a.score)
const maxScore = scoredPosts[0]?.score ?? 0
const weightedPosts = scoredPosts.map (item => ({
...item,
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
const totalWeight =
weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1
const normalisedWeightedPosts =
weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight }))
const signatureFor = (
question: GekanatorQuestion,
candidates: { post: Post; score: number }[],
): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('')
const invertedSignature = (signature: string): string =>
signature.replace (/[01]/g, value => value === '1' ? '0' : '1')
const redundantSignatures = (
candidates: { post: Post; score: number }[],
): Set<string> => {
const signatures = new Set<string> ()
questions
.filter (question => askedIds.has (question.id))
.forEach (question => {
const signature = signatureFor (question, candidates)
signatures.add (signature)
signatures.add (invertedSignature (signature))
})
return signatures
}
const rank = (
questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[],
weightedCandidates: { post: Post; score: number; weight: number }[],
) => {
const redundant = redundantSignatures (candidates)
const nonTagCount =
questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length
return questionsToRank
.map (question => {
if (isQuestionRedundantAfterAnswers (question, answers))
return null
const signature = signatureFor (question, candidates)
if (redundant.has (signature))
return null
const yes = signature.split ('').filter (value => value === '1').length
const no = candidates.length - yes
if (yes === 0 || no === 0)
return null
const yesWeight = weightedCandidates.reduce (
(sum, item) => sum + (question.test (item.post) ? item.weight : 0),
0)
const noWeight = 1 - yesWeight
if (yesWeight <= 0 || noWeight <= 0)
return null
const weightedSplitScore = Math.abs (.5 - yesWeight)
const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
const sourceBonus = sourcePriorityOffset (question)
const priorityBonus = priorityWeightOffset (question)
return { question,
score: weightedSplitScore * 100
+ unweightedSplitScore * 8
+ tagPenalty
+ narrowPenalty
+ contradictionPenalty
+ sourceBonus
+ priorityBonus,
narrow: narrowPenalty > 0 }
})
.filter ((item): item is {
question: GekanatorQuestion
score: number
narrow: boolean } => item !== null && Number.isFinite (item.score))
.sort ((a, b) => a.score - b.score)
}
const unansweredQuestions =
questions.filter (question => !(askedIds.has (question.id)))
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
const pool = (
ranked.some (item => !(item.narrow))
? ranked.filter (item => !(item.narrow))
: ranked)
.slice (0, 12)
if (pool.length === 0)
return null
const bestScore = pool[0]?.score ?? 0
const weightedPool = pool.map (item => ({
...item,
weight: Math.exp ((bestScore - item.score) / 1.8) }))
const totalPoolWeight =
weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1
const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${
weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|')
}`
const target = deterministicUnitFloat (seed) * totalPoolWeight
let cumulative = 0
for (const item of weightedPool)
{
cumulative += item.weight
if (target <= cumulative)
return item.question
}
return weightedPool[weightedPool.length - 1]?.question ?? null
}
const bestPost = (posts: Post[], scores: Map<number, number>): Post | null =>
posts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
.sort ((a, b) => b.score - a.score)[0]?.post ?? null
const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
<div className="flex gap-3 items-center min-w-0">
<img
src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
className="w-16 h-16 rounded object-cover bg-yellow-100"/>
<div className="min-w-0">
<PrefetchLink
to={`/posts/${ post.id }`}
className="font-bold text-pink-700 dark:text-pink-200 break-words">
#{post.id} {post.title || post.url}
</PrefetchLink>
<div className="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-1">
{post.tags.slice (0, 6).map (tag => tag.name).join (' / ')}
</div>
</div>
</div>)
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): 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')
const [scores, setScores] = useState<Map<number, number>> (
() => new Map (storedGame?.scores ?? []))
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> (
storedGame?.answers ?? [])
const [askedIds, setAskedIds] = useState<Set<string>> (
() => new Set (storedGame?.askedIds ?? []))
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
() => new Set (storedGame?.softenedQuestionIds ?? []))
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
(storedGame?.askedQuestionBank?.length ?? 0) > 0
? []
: storedGame?.askedQuestionBankIds ?? [])
const [search, setSearch] = useState (storedGame?.search ?? '')
const [selectingCorrectPost, setSelectingCorrectPost] = useState (
storedGame?.selectingCorrectPost ?? false)
const [saved, setSaved] = useState (storedGame?.saved ?? false)
const [resultWon, setResultWon] = useState<boolean | null> (
storedGame?.resultWon ?? null)
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (
() => new Set (storedGame?.rejectedPostIds ?? []))
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (
storedGame?.lastGuessQuestionCount ?? 0)
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (
storedGame?.lastRejectedGuessId ?? null)
const [activeGuessId, setActiveGuessId] = useState<number | null> (
storedGame?.activeGuessId ?? null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (
storedGame?.reviewGuessedPostId ?? null)
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (
storedGame?.reviewCorrectPostId ?? null)
const [savedGameId, setSavedGameId] = useState<number | null> (
storedGame?.savedGameId ?? null)
const [questionSuggestion, setQuestionSuggestion] = useState (
storedGame?.questionSuggestion ?? '')
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
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 ({
queryKey: gekanatorKeys.posts (),
queryFn: fetchGekanatorPosts,
refetchOnWindowFocus: false })
const {
data: acceptedQuestions = [],
isFetched: acceptedQuestionsFetched,
isLoading: acceptedQuestionsLoading,
error: acceptedQuestionsError
} = useQuery ({
queryKey: gekanatorKeys.questions (),
queryFn: fetchGekanatorQuestions,
select: questions => questions.map (restoreGekanatorQuestion),
refetchOnWindowFocus: false })
useEffect (() => {
if (
posts.length === 0
|| storedAskedQuestionBankIds.length === 0
|| !(acceptedQuestionsFetched)
)
return
const questionById = new Map (
mergeQuestions ([
...buildGekanatorQuestions (posts),
...acceptedQuestions])
.map (question => [question.id, question]))
setAskedQuestionBank (
storedAskedQuestionBankIds
.map (questionId => questionById.get (questionId))
.filter ((question): question is GekanatorQuestion => question !== undefined))
setStoredAskedQuestionBankIds ([])
}, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched])
useEffect (() => {
if (!(isStoredPhase (phase)) && answers.length === 0)
{
clearStoredGame ()
return
}
const stored: StoredGekanatorGame = {
phase,
scores: [...scores.entries ()],
answers,
askedIds: [...askedIds],
softenedQuestionIds: [...softenedQuestionIds],
askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion),
askedQuestionBankIds: storedAskedQuestionBankIds,
search,
selectingCorrectPost,
saved,
resultWon,
rejectedPostIds: [...rejectedPostIds],
lastGuessQuestionCount,
lastRejectedGuessId,
activeGuessId,
reviewGuessedPostId,
reviewCorrectPostId,
savedGameId,
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState }
try
{
sessionStorage.setItem (gameStorageKey, JSON.stringify (stored))
}
catch
{
return
}
}, [
phase,
scores,
answers,
askedIds,
softenedQuestionIds,
askedQuestionBank,
storedAskedQuestionBankIds,
search,
selectingCorrectPost,
saved,
resultWon,
rejectedPostIds,
lastGuessQuestionCount,
lastRejectedGuessId,
activeGuessId,
reviewGuessedPostId,
reviewCorrectPostId,
savedGameId,
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState])
const eligiblePosts = useMemo (
() => candidatePostsFor ({
posts,
questions: askedQuestionBank,
answers,
softenedQuestionIds,
rejectedPostIds }),
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
const questions = useMemo (
() => mergeQuestions ([
...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
...acceptedQuestions]),
[acceptedQuestions, eligiblePosts, posts])
const scoringQuestions = useMemo (() => {
return mergeQuestions ([...questions, ...askedQuestionBank])
}, [questions, askedQuestionBank])
const scoringQuestionById = useMemo (
() => new Map (scoringQuestions.map (question => [question.id, question])),
[scoringQuestions])
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
const nonRejectedPosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
[posts, rejectedPostIds])
const questionPosts =
eligiblePosts.length > 1
|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
? eligiblePosts
: nonRejectedPosts
const topScoredPosts = useMemo (
() => eligiblePosts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
.sort ((a, b) => b.score - a.score)
.slice (0, 3),
[eligiblePosts, scores])
const currentQuestion = chooseQuestion ({
posts: questionPosts,
questions: scoringQuestions,
scores,
answers,
askedIds,
gameSeed })
const answerPreviews = useMemo (
() => currentQuestion
? answerOptions.map (option => previewAnswer ({
posts: eligiblePosts,
scores,
question: currentQuestion,
answer: option.value }))
: [],
[currentQuestion, eligiblePosts, scores])
const guessablePosts =
eligiblePosts.length > 0
? eligiblePosts
: nonRejectedPosts
const guess = bestPost (guessablePosts, scores)
const displayedGuess =
posts.find (post => post.id === activeGuessId) ?? guess
const reviewGuessedPost =
posts.find (post => post.id === reviewGuessedPostId) ?? null
const reviewCorrectPost =
posts.find (post => post.id === reviewCorrectPostId) ?? null
const saveMutation = useMutation ({
mutationFn: saveGekanatorGame,
onSuccess: (data, variables) => {
setSaved (true)
setSavedGameId (data.id)
setResultWon (variables.guessedPostId === variables.correctPostId)
}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: async data => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setQuestionSuggestionCount (data.count)
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
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 ([])
setAskedIds (new Set ())
setSoftenedQuestionIds (new Set ())
setAskedQuestionBank ([])
setSearch ('')
setSelectingCorrectPost (false)
setSaved (false)
setResultWon (null)
setRejectedPostIds (new Set ())
setLastGuessQuestionCount (0)
setLastRejectedGuessId (null)
setActiveGuessId (null)
setReviewGuessedPostId (null)
setReviewCorrectPostId (null)
setSavedGameId (null)
setGameSeed (createGameSeed ())
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
setQuestionSuggestionCount (0)
resetExtraQuestionState ()
setHistory ([])
}
const recoverQuestionState = ({
nextAnswers,
nextAskedIds,
nextAskedQuestionBank,
nextSoftenedQuestionIds,
nextRejectedPostIds,
}: {
nextAnswers: GekanatorAnswerLog[]
nextAskedIds: Set<string>
nextAskedQuestionBank: GekanatorQuestion[]
nextSoftenedQuestionIds: Set<string>
nextRejectedPostIds: Set<number>
}) => {
let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
let recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
let recoveredEligiblePosts = candidatePostsFor ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
let recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...acceptedQuestions,
...nextAskedQuestionBank])
while (
recoveredEligiblePosts.length === 0
|| (
recoveredEligiblePosts.length !== 1
&& !(chooseQuestion ({
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })))
)
{
if (nextAnswers.length >= hardMaxQuestions)
break
const softened = softenNextQuestionIds ({
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
if (!(softened))
break
recoveredSoftenedQuestionIds = softened
recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
recoveredEligiblePosts = candidatePostsFor ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...acceptedQuestions,
...nextAskedQuestionBank])
}
return {
softenedQuestionIds: recoveredSoftenedQuestionIds,
scores: recoveredScores,
eligiblePosts: recoveredEligiblePosts,
scoringQuestions: recoveredScoringQuestions }
}
const answer = (value: GekanatorAnswerValue) => {
if (!(currentQuestion))
{
setActiveGuessId (guess?.id ?? null)
setPhase ('guess')
return
}
setHistory ([...history, {
phase,
scores: new Map (scores),
answers: [...answers],
askedIds: new Set (askedIds),
softenedQuestionIds: new Set (softenedQuestionIds),
askedQuestionBank: [...askedQuestionBank],
search,
selectingCorrectPost,
rejectedPostIds: new Set (rejectedPostIds),
lastGuessQuestionCount,
lastRejectedGuessId,
activeGuessId,
reviewGuessedPostId,
reviewCorrectPostId }])
const nextAnswers = [...answers, {
questionId: currentQuestion.id,
questionText: currentQuestion.text,
questionCondition: currentQuestion.condition,
answer: value,
originalAnswer: value }]
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
const nextAskedQuestionBank = [
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
currentQuestion]
const recovered = recoverQuestionState ({
nextAnswers,
nextAskedIds,
nextAskedQuestionBank,
nextSoftenedQuestionIds: softenedQuestionIds,
nextRejectedPostIds: rejectedPostIds })
const nextSoftenedQuestionIds = recovered.softenedQuestionIds
const nextScores = recovered.scores
const nextEligiblePosts = recovered.eligiblePosts
setScores (nextScores)
setAskedIds (nextAskedIds)
setSoftenedQuestionIds (nextSoftenedQuestionIds)
setAskedQuestionBank (nextAskedQuestionBank)
setAnswers (nextAnswers)
const nextGuessablePosts =
nextEligiblePosts.length > 0
? nextEligiblePosts
: nonRejectedPosts
const nextGuess = bestPost (nextGuessablePosts, nextScores)
const nextQuestionCount = answers.length + 1
const nextQuestionsSinceLastGuess =
nextQuestionCount - lastGuessQuestionCount
const nextConfidences = confidencesFor (nextGuessablePosts, nextScores)
const topConfidence = nextConfidences[0] ?? null
const runnerUpConfidence = nextConfidences[1] ?? null
const structurallyCertain = nextEligiblePosts.length === 1
const statisticallyCertain =
topConfidence !== null
&& topConfidence.percent >= certainGuessPercent
&& (runnerUpConfidence === null
|| runnerUpConfidence.percent <= runnerUpMaxPercent)
const canGuessByQuestionCount =
nextQuestionsSinceLastGuess >= questionsBetweenGuesses
const canGuessEarlyByConfidence =
nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess
&& (structurallyCertain || statisticallyCertain)
const shouldGuess =
nextQuestionCount >= hardMaxQuestions
|| canGuessByQuestionCount
|| canGuessEarlyByConfidence
if (shouldGuess)
{
setActiveGuessId (nextGuess?.id ?? null)
setLastGuessQuestionCount (nextQuestionCount)
setPhase ('guess')
}
}
const finishGame = (correctPostId: number) => {
const guessedPostId =
phase === 'end' || phase === 'review'
? reviewGuessedPostId
: phase === 'continue'
? lastRejectedGuessId ?? displayedGuess?.id
: displayedGuess?.id ?? lastRejectedGuessId
if (!(guessedPostId))
return
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setReviewGuessedPostId (guessedPostId)
setReviewCorrectPostId (correctPostId)
setSearch ('')
setSelectingCorrectPost (false)
setPhase ('end')
}
const startReview = () => {
if (reviewGuessedPostId === null || reviewCorrectPostId === null)
return
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setSelectingCorrectPost (false)
setSearch ('')
setPhase ('review')
}
const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
if (
reviewGuessedPostId === null
|| reviewCorrectPostId === null
|| saveMutation.isPending
)
return
if (savedGameId !== null)
{
onSuccess (savedGameId)
return
}
saveMutation.mutate ({
guessedPostId: reviewGuessedPostId,
correctPostId: reviewCorrectPostId,
answers },
{ onSuccess: data => onSuccess (data.id) })
}
const saveAndReset = () => {
saveReviewedResult (reset)
}
const saveAndLearn = () => {
resetExtraQuestionState ()
saveReviewedResult (() => setPhase ('learned'))
}
const restartFromQuestionSuggestion = () => {
if (savedGameId !== null)
{
reset ()
return
}
saveReviewedResult (reset)
}
const submitQuestionSuggestion = () => {
const questionText = questionSuggestion.trim ()
if (
!(questionText)
|| questionSuggestionMutation.isPending
|| questionSuggestionCount >= maxQuestionSuggestionsPerGame
)
return
saveReviewedResult (gekanatorGameId => {
questionSuggestionMutation.mutate ({
gekanatorGameId,
questionText,
answer: questionSuggestionAnswer })
})
}
const rejectGuess = () => {
if (!(displayedGuess))
return
setLastRejectedGuessId (displayedGuess.id)
if (answers.length >= hardMaxQuestions)
{
setSelectingCorrectPost (true)
return
}
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
setActiveGuessId (null)
setSearch ('')
setSelectingCorrectPost (false)
setLastGuessQuestionCount (answers.length)
setPhase ('continue')
}
const undoAnswer = () => {
const snapshot = history[history.length - 1]
if (!(snapshot) || saved)
return
setPhase (snapshot.phase)
setScores (snapshot.scores)
setAnswers (snapshot.answers)
setAskedIds (snapshot.askedIds)
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
setAskedQuestionBank (snapshot.askedQuestionBank)
setSearch (snapshot.search)
setSelectingCorrectPost (snapshot.selectingCorrectPost)
setRejectedPostIds (snapshot.rejectedPostIds)
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
setActiveGuessId (snapshot.activeGuessId)
setReviewGuessedPostId (snapshot.reviewGuessedPostId)
setReviewCorrectPostId (snapshot.reviewCorrectPostId)
setHistory (history.slice (0, -1))
}
const continueGame = () => {
setSearch ('')
setSelectingCorrectPost (false)
const recovered = recoverQuestionState ({
nextAnswers: answers,
nextAskedIds: askedIds,
nextAskedQuestionBank: askedQuestionBank,
nextSoftenedQuestionIds: softenedQuestionIds,
nextRejectedPostIds: rejectedPostIds })
setSoftenedQuestionIds (recovered.softenedQuestionIds)
setScores (recovered.scores)
const nextQuestion = chooseQuestion ({
posts: recovered.eligiblePosts.length > 1
? recovered.eligiblePosts
: nonRejectedPosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
answers,
askedIds,
gameSeed })
if (nextQuestion)
{
setPhase ('question')
return
}
setActiveGuessId (guess?.id ?? null)
setPhase ('guess')
}
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer))
}
const selectCorrectPost = (post: Post) => {
if (phase === 'review')
{
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false)
setSearch ('')
return
}
finishGame (post.id)
}
const filteredPosts = posts
.filter (post => {
const needle = search.trim ().toLowerCase ()
if (!(needle))
return false
if (/^\d+$/.test (needle) && post.id === Number (needle))
return true
return [post.title, post.url, ...post.tags.map (tag => tag.name)]
.filter ((value): value is string => Boolean (value))
.some (value => value.toLowerCase ().includes (needle))
})
.sort ((a, b) => {
const id = Number (search.trim ())
if (Number.isFinite (id))
return Number (b.id === id) - Number (a.id === id)
return 0
})
.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>!</>
: <><ruby>鹿<rt></rt></ruby>稿</>
const introLoading = isLoading || acceptedQuestionsLoading
const readyToStart =
!(introLoading)
&& acceptedQuestionsFetched
&& posts.length > 0
&& !(error)
&& !(acceptedQuestionsError)
return (
<MainArea className="bg-yellow-50 dark:bg-red-975">
<Helmet>
<meta name="robots" content="noindex"/>
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-4xl space-y-6">
<header className="space-y-2">
<p className="text-sm text-pink-700 dark:text-pink-200"></p>
<h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
</h1>
</header>
<section className="rounded-lg border border-yellow-300 bg-white p-4
shadow-sm dark:border-red-800 dark:bg-red-950">
<div className="flex gap-4">
<div className="grid h-24 w-24 shrink-0 place-items-center rounded-lg
bg-yellow-200 text-center text-sm font-bold
text-pink-700 dark:bg-red-900 dark:text-pink-100">
鹿
</div>
<div className="min-w-0 flex-1 space-y-3">
<p className="text-lg font-bold">
{dialogue}
</p>
{introLoading && (
<p>
{phase === 'intro'
? '投稿を読み込んでゐます...'
: '前回のグカネータ状態を復元してゐます...'}
</p>)}
{(Boolean (error) || Boolean (acceptedQuestionsError))
&& <p></p>}
{phase === 'intro' && readyToStart && (
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500"
onClick={() => setPhase ('question')}>
</button>)}
{phase === 'question' && currentQuestion && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500">
{answers.length + 1}
</p>
<p className="text-xl font-bold">{currentQuestion.text}</p>
</div>
<div className="rounded border border-yellow-100 px-3 py-2
text-sm dark:border-red-900">
<div className="font-bold">: {eligiblePosts.length} </div>
{topScoredPosts.length > 0 && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{topScoredPosts.map (item => (
<span key={item.post.id}>
#{item.post.id}: score {item.score.toFixed (1)}
</span>))}
</div>)}
</div>
{answerPreviews.length > 0 && (
<div className="grid gap-2 text-sm md:grid-cols-2">
{answerOptions.map (option => {
const preview =
answerPreviews.find (item => item.answer === option.value)
return (
<div
key={option.value}
className="rounded border border-yellow-100 px-3 py-2
dark:border-red-900">
<span className="font-bold">{option.label}</span>
{' '}
<span className="text-neutral-600 dark:text-neutral-300">
{preview ? preview.candidateCount : 0}
</span>
</div>)
})}
</div>)}
<div className="flex flex-wrap gap-2">
{answerOptions.map (option => (
<button
key={option.value}
type="button"
className="rounded border border-yellow-300 px-3 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => answer (option.value)}>
{option.label}
</button>))}
{history.length > 0 && (
<button
type="button"
className="rounded border border-neutral-300 px-3 py-2
hover:bg-neutral-100 dark:border-neutral-700
dark:hover:bg-red-900"
onClick={undoAnswer}>
</button>)}
</div>
</div>)}
{!(isLoading) && phase === 'question' && !(currentQuestion) && (
<div className="space-y-4">
<p className="text-xl font-bold">
</p>
<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"
onClick={() => {
setActiveGuessId (guess?.id ?? null)
setPhase ('guess')
}}>
</button>
</div>)}
{phase === 'guess' && displayedGuess && (
<div className="space-y-4">
<p className="text-xl font-bold">?</p>
<PostMiniCard post={displayedGuess}/>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500"
onClick={() => {
if (displayedGuess)
finishGame (displayedGuess.id)
}}>
</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"
onClick={rejectGuess}>
</button>
{history.length > 0 && (
<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"
onClick={undoAnswer}>
</button>)}
</div>
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
</div>)}
{phase === 'continue' && (
<div className="space-y-4">
<p className="text-xl font-bold">?</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500"
onClick={continueGame}>
</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"
onClick={() => setSelectingCorrectPost (true)}>
</button>
{history.length > 0 && (
<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"
onClick={undoAnswer}>
</button>)}
</div>
</div>)}
{phase === 'end' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold">wwwww</p>
</div>
{reviewGuessedPost && (
<div className="space-y-2">
<div className="font-bold">稿</div>
<PostMiniCard post={reviewGuessedPost}/>
</div>)}
<div className="space-y-2">
<div className="font-bold">稿</div>
{reviewCorrectPost
? <PostMiniCard post={reviewCorrectPost}/>
: <p className="text-sm text-red-600">稿</p>}
<button
type="button"
className="rounded border border-yellow-300 px-3 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => setSelectingCorrectPost (true)}>
稿
</button>
</div>
{reviewGuessedPostId !== null && reviewCorrectPostId !== null && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'}
</p>)}
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50"
disabled={reviewCorrectPostId === null || saveMutation.isPending}
onClick={saveAndReset}>
</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={reviewCorrectPostId === null || saveMutation.isPending}
onClick={startReview}>
</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={saveMutation.isPending
|| questionSuggestionMutation.isPending}
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>)}
{phase === 'review' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"></p>
</div>
{reviewGuessedPost && (
<div className="space-y-2">
<div className="font-bold">稿</div>
<PostMiniCard post={reviewGuessedPost}/>
</div>)}
<div className="space-y-2">
<div className="font-bold">稿</div>
{reviewCorrectPost
? <PostMiniCard post={reviewCorrectPost}/>
: <p className="text-sm text-red-600">稿</p>}
<button
type="button"
className="rounded border border-yellow-300 px-3 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => setSelectingCorrectPost (true)}>
稿
</button>
</div>
<div className="space-y-2">
<div className="font-bold"></div>
<div className="space-y-2">
{answers.map ((answer, index) => {
const expectedAnswer = expectedAnswerFor (
scoringQuestionById.get (answer.questionId),
reviewCorrectPost)
return (
<div
key={`${ answer.questionId }:${ index }`}
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">{answer.questionText}</div>
<div className="mt-2 grid gap-1 text-sm md:grid-cols-3">
<div>
<span className="text-neutral-500">: </span>
{expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'}
</div>
<div>
<span className="text-neutral-500">: </span>
{answerLabelFor (answer.originalAnswer)}
</div>
<label className="block">
<span className="text-neutral-500">: </span>
<select
value={answer.answer}
className="rounded border border-yellow-300 bg-white px-2
py-1
dark:border-red-700 dark:bg-red-950"
onChange={ev =>
correctAnswerAt (
index,
ev.target.value as GekanatorAnswerValue)}>
{answerOptions.map (option => (
<option key={option.value} value={option.value}>
{option.label}
</option>))}
</select>
</label>
</div>
</div>)
})}
</div>
</div>
{reviewGuessedPostId !== null && reviewCorrectPostId !== null && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'}
</p>)}
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50"
disabled={
reviewCorrectPostId === null
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
}
onClick={saveAndLearn}>
</button>
<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"
onClick={() => setPhase ('end')}>
</button>
</div>
</div>)}
{phase === 'question_suggestion' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold">?</p>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
{questionSuggestionCount} / {maxQuestionSuggestionsPerGame}
</p>
</div>
<label className="block space-y-2">
<span className="font-bold"></span>
<textarea
value={questionSuggestion}
onChange={ev => setQuestionSuggestion (ev.target.value)}
className="min-h-24 w-full rounded border border-yellow-300
bg-white px-3 py-2 dark:border-red-700
dark:bg-red-950"/>
</label>
<label className="block space-y-2">
<span className="font-bold">稿</span>
<select
value={questionSuggestionAnswer}
className="rounded border border-yellow-300 bg-white px-2 py-1
dark:border-red-700 dark:bg-red-950"
onChange={ev =>
setQuestionSuggestionAnswer (
ev.target.value as GekanatorAnswerValue)}>
{answerOptions.map (option => (
<option key={option.value} value={option.value}>
{option.label}
</option>))}
</select>
</label>
<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={saveMutation.isPending
|| questionSuggestionMutation.isPending}
onClick={() => setPhase ('end')}>
</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={
questionSuggestionCount >= maxQuestionSuggestionsPerGame
|| reviewCorrectPostId === null
|| questionSuggestion.trim () === ''
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
}
onClick={submitQuestionSuggestion}>
</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={saveMutation.isPending
|| questionSuggestionMutation.isPending}
onClick={restartFromQuestionSuggestion}>
</button>
</div>
{questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
</p>)}
{(saveMutation.isError || questionSuggestionMutation.isError) && (
<p className="text-sm text-red-600">
</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>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500"
onClick={reset}>
</button>
</div>)}
</div>
</div>
</section>
{['guess', 'continue', 'question', 'end', 'review'].includes (phase)
&& selectingCorrectPost && (
<section className="rounded-lg border border-yellow-300 bg-white p-4
dark:border-red-800 dark:bg-red-950">
<label className="block space-y-2">
<span className="font-bold">稿</span>
<input
value={search}
onChange={ev => setSearch (ev.target.value)}
className="w-full rounded border border-yellow-300 bg-white px-3 py-2
dark:border-red-700 dark:bg-red-950"
placeholder="投稿 Id.・タイトル・URL・タグで検索"/>
</label>
<div className="mt-4 space-y-3">
{filteredPosts.map (post => (
<button
key={post.id}
type="button"
className={cn ('block w-full rounded border border-yellow-200 p-3',
'text-left hover:bg-yellow-100',
'dark:border-red-800 dark:hover:bg-red-900')}
onClick={() => selectCorrectPost (post)}>
<PostMiniCard post={post}/>
</button>))}
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
{saveMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
</div>
</section>)}
</div>
</MainArea>)
}
export default GekanatorPage