グカネータ改良 (#371) #375

マージ済み
みてるぞ が 7 個のコミットを feature/371 から main へマージ 2026-06-17 01:04:58 +09:00
2個のファイルの変更337行の追加270行の削除
コミット cb7b9ee808 の変更だけを表示してゐます - すべてのコミットを表示
+10
ファイルの表示
@@ -173,6 +173,16 @@ const value =
- In TypeScript and TSX, keep short ternary expressions on one line when they
fit cleanly under the line limit.
- In TypeScript and TSX, prefer ternary expressions for simple conditional
value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a
ternary expression.
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
when the value can be expressed as a single `const` initializer. Prefer
`const` because it prevents accidental later reassignment.
- When fixing formatting, change formatting only. Do not change expression
structure, control flow, or variable mutability unless the requested style
explicitly requires it.
- Do not add production dependencies without explicit approval.
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until
+327 -270
ファイルの表示
@@ -25,6 +25,7 @@ import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
import type { Transition } from 'framer-motion'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
@@ -243,16 +244,23 @@ const normalizeStoredQuestionId = (
const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({
...game,
answers: game.answers.map (answer => ({
...answer,
questionId: normalizeStoredQuestionId (answer.questionId,
answer.questionCondition),
questionMode: ((answer.questionMode === 'winning_run' || answer.questionMode === 'normal')
? answer.questionMode
: undefined),
questionCondition: (answer.questionCondition
? normalizeTitleLengthCondition (answer.questionCondition)
: undefined) })),
answers: game.answers.map (answer => {
const questionMode =
answer.questionMode === 'winning_run' || answer.questionMode === 'normal'
? answer.questionMode
: undefined
const questionCondition =
answer.questionCondition
? normalizeTitleLengthCondition (answer.questionCondition)
: undefined
return {
...answer,
questionId: normalizeStoredQuestionId (answer.questionId,
answer.questionCondition),
questionMode,
questionCondition }
}),
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
softenedQuestionIds: (game.softenedQuestionIds
.map (questionId => normalizeStoredQuestionId (questionId))),
@@ -474,10 +482,12 @@ const questionCategoryPenalty = (
repeatPenalty: number,
): number => {
const earlyFactor = Math.max (0, (3 - answerCount) / 3)
const titleLengthPenalty =
titleLengthMinimumForCondition (question.condition) == null
? 0
: (answerCount === 0 ? 8 : 3.5) * earlyFactor
const titleLengthPenalty = (() => {
if (titleLengthMinimumForCondition (question.condition) == null)
return 0
return (answerCount === 0 ? 8 : 3.5) * earlyFactor
}) ()
switch (question.kind)
{
@@ -649,21 +659,21 @@ const buildMaterialIndex = (
const originalValue = post.originalCreatedFrom || post.originalCreatedBefore
const date =
originalValue
? new Date (originalValue)
: null
? new Date (originalValue)
: null
const validDate =
date && !(Number.isNaN (date.getTime ()))
? date
: null
? date
: null
const originalYear = validDate?.getFullYear () ?? null
const originalMonth =
validDate
? validDate.getMonth () + 1
: null
? validDate.getMonth () + 1
: null
const originalMonthDay =
validDate
? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }`
: null
? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }`
: null
originalYearByPostId.set (post.id, originalYear)
originalMonthByPostId.set (post.id, originalMonth)
originalMonthDayByPostId.set (post.id, originalMonthDay)
@@ -758,10 +768,10 @@ const humanPriorityOffsetFor = (question: GekanatorQuestion): number => {
case 'source':
return -2.5
case 'post_similarity':
return (
question.source === 'user_suggested' || question.source === 'admin_curated'
? -3.5
: -1.5)
if (question.source === 'user_suggested' || question.source === 'admin_curated')
return -3.5
return -1.5
case 'original_date':
switch (question.condition.type)
{
@@ -1197,10 +1207,12 @@ const buildQuestionsForCandidateIds = (
confirmationPostId?: number | null },
): GekanatorQuestion[] => {
const total = candidateIds.length
const confirmationPost =
confirmationPostId == null
? null
: materialIndex.postById.get (confirmationPostId) ?? null
const confirmationPost = (() => {
if (confirmationPostId == null)
return null
return materialIndex.postById.get (confirmationPostId) ?? null
}) ()
if (mode === 'split' && total === 0)
return acceptedQuestions
@@ -1251,17 +1263,23 @@ const buildQuestionsForCandidateIds = (
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): GekanatorQuestion => buildIndexedQuestion ({
): GekanatorQuestion => {
const priorityWeight = (() => {
if (condition.type === 'original-year')
return 1.04
if (condition.type === 'original-month-day')
return 1.01
return .92
}) ()
return buildIndexedQuestion ({
condition,
text: originalDateQuestionTextFor (condition),
kind: 'original_date',
priorityWeight:
condition.type === 'original-year'
? 1.04
: condition.type === 'original-month-day'
? 1.01
: .92,
priorityWeight,
materialIndex })
}
const specialMonthDays = rankedEntriesForCounts ({
counts: monthDayCounts,
total,
@@ -1342,27 +1360,21 @@ const buildQuestionsForCandidateIds = (
const titleLength = materialIndex.titleLengthByPostId.get (targetPostId) ?? 0
const tagKeys = materialIndex.tagKeysByPostId.get (targetPostId) ?? []
addQuestion (
host
? buildIndexedQuestion ({
condition: { type: 'source', host },
text: `${ host } の投稿を思い浮かべている?`,
kind: 'source',
priorityWeight: 1.02,
materialIndex })
: null)
addQuestion (
year != null && year != undefined
? buildDateQuestion ({
type: 'original-year',
year })
: null)
addQuestion (
monthDay && specialOriginalMonthDayLabelFor (monthDay)
? buildDateQuestion ({
type: 'original-month-day',
monthDay })
: null)
if (host)
addQuestion (buildIndexedQuestion ({
condition: { type: 'source', host },
text: `${ host } の投稿を思い浮かべている?`,
kind: 'source',
priorityWeight: 1.02,
materialIndex }))
if (year != null)
addQuestion (buildDateQuestion ({
type: 'original-year',
year }))
if (monthDay && specialOriginalMonthDayLabelFor (monthDay))
addQuestion (buildDateQuestion ({
type: 'original-month-day',
monthDay }))
tagKeys
.slice (0, 20)
@@ -1451,16 +1463,19 @@ const candidatePostsForState = ({
if (!(condition))
return true
const matched = question
? matchingPostIdsForQuestion ({
const matched = (() => {
if (question)
return matchingPostIdsForQuestion ({
posts,
materialIndex,
matchIndex,
question,
dynamicMatchIndex })
: matchingPostIdsForCondition ({
condition,
materialIndex })
return matchingPostIdsForCondition ({
condition,
materialIndex })
}) ()
const useExpectedAnswer =
question != null
&& usesLearnedTagExamples (question)
@@ -1477,9 +1492,7 @@ const candidatePostsForState = ({
|| expected === answer.answer
}
return answer.answer === 'yes'
? matched.has (post.id)
: !(matched.has (post.id))
return answer.answer === 'yes' ? matched.has (post.id) : !(matched.has (post.id))
}
if (!(question))
@@ -1839,17 +1852,21 @@ const contradictionPenaltyFor = ({
case 'partial':
return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
case 'no':
return sum + (
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
? 40
: 0)
|| isMonthCrossMatch (question.condition, previous)
)
return sum + 40
return sum
case 'probably_no':
return sum + (
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
? 20
: 0)
|| isMonthCrossMatch (question.condition, previous)
)
return sum + 20
return sum
default:
return sum
}
@@ -2003,28 +2020,34 @@ const chooseQuestion = (
const humanOffset = humanPriorityOffsetFor (question)
const sourceBonus = sourcePriorityOffset (question)
const priorityBonus = priorityWeightOffset (question)
const repeatPenalty =
answers.length === 0
? (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
: 0
const repeatPenalty = (() => {
if (answers.length === 0)
return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
return 0
}) ()
const categoryPenalty = questionCategoryPenalty (
question,
answers.length,
repeatPenalty)
const priorSplitScore =
priorWeightTotal <= 0
? null
: Math.abs (
.5 - (
priorEntries.reduce (
(sum, [postId, weight]) => {
return sum + (matched.has (postId) ? weight : 0)
},
0) / priorWeightTotal))
const priorBonus =
priorSplitScore == null
? 0
: Math.max (0, .22 - priorSplitScore) * -18
const priorSplitScore = (() => {
if (priorWeightTotal <= 0)
return null
return Math.abs (
.5 - (
priorEntries.reduce (
(sum, [postId, weight]) => {
return sum + (matched.has (postId) ? weight : 0)
},
0) / priorWeightTotal))
}) ()
const priorBonus = (() => {
if (priorSplitScore == null)
return 0
return Math.max (0, .22 - priorSplitScore) * -18
}) ()
const infoGainBonus = -Math.min (1.2, infoGain) * 4
return { question,
@@ -2052,9 +2075,7 @@ const chooseQuestion = (
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)
ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked)
.slice (0, 16)
if (pool.length === 0)
@@ -2133,10 +2154,7 @@ const chooseWinningRunQuestion = ({
})
.map (question => {
const expected = expectedAnswerForQuestion (question, targetPost)
const priority =
expected == null
? null
: winningRunPriorityFor (expected)
const priority = expected == null ? null : winningRunPriorityFor (expected)
if (priority == null)
return null
@@ -2148,9 +2166,7 @@ const chooseWinningRunQuestion = ({
question,
dynamicMatchIndex })
const matchingCount =
expected === 'yes' || expected === 'partial'
? yesCount
: posts.length - yesCount
expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount
return {
question,
@@ -2291,12 +2307,15 @@ const isWinningRunActive = (
const winningRunQuestionCount = (
answers: GekanatorAnswerLog[],
winningRunStartAnswerCount: number | null,
): number => winningRunStartAnswerCount == null
? 0
: answers
.slice (winningRunStartAnswerCount)
.filter (answer => answer.questionMode === 'winning_run')
.length
): number => {
if (winningRunStartAnswerCount == null)
return 0
return answers
.slice (winningRunStartAnswerCount)
.filter (answer => answer.questionMode === 'winning_run')
.length
}
const nextQuestionPlanFor = (
@@ -2335,10 +2354,7 @@ const nextQuestionPlanFor = (
questionMode: QuestionMode
winningRunTargetId: number | null
winningRunStartAnswerCount: number | null } => {
const guessablePosts =
eligiblePosts.length > 0
? eligiblePosts
: availablePosts
const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts
const checkpointGuess =
answers.length > 0
@@ -2366,22 +2382,25 @@ const nextQuestionPlanFor = (
winningRunStartAnswerCount }
}
const nextWinningRunTargetId =
eligiblePosts.length === 1
? eligiblePosts[0]?.id ?? null
: null
const nextWinningRunStartAnswerCount =
nextWinningRunTargetId == null
? null
: ((isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null)
? winningRunStartAnswerCount
: answers.length)
const nextWinningRunTargetPost =
nextWinningRunTargetId == null
? null
: posts.find (post => post.id === nextWinningRunTargetId) ?? null
const nextWinningRunTargetId = eligiblePosts.length === 1 ? eligiblePosts[0]?.id ?? null : null
const nextWinningRunStartAnswerCount = (() => {
if (nextWinningRunTargetId == null)
return null
if (
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null
)
return winningRunStartAnswerCount
return answers.length
}) ()
const nextWinningRunTargetPost = (() => {
if (nextWinningRunTargetId == null)
return null
return posts.find (post => post.id === nextWinningRunTargetId) ?? null
}) ()
const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] =>
buildQuestionsForCandidateIds ({
candidateIds: scopePosts.map (post => post.id),
@@ -2580,12 +2599,12 @@ const backgroundPostsFor = ({
}): Post[] => {
const focusPosts =
phase === 'end' || phase === 'review' || phase === 'learned'
? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null)
: phase === 'guess'
? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null)
: eligiblePosts.length > 0
? eligiblePosts
: availablePosts
? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null)
: phase === 'guess'
? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null)
: eligiblePosts.length > 0
? eligiblePosts
: availablePosts
return [...new Map (focusPosts.map (post => [post.id, post])).values ()]
}
@@ -2634,20 +2653,20 @@ const GekanatorBackdrop: FC<{
{ x: -33.333333, y: -33.333333 }],
[])
const guessThumbnail =
phase === 'guess' && displayedGuess
? backgroundThumbnailUrl (displayedGuess)
: null
phase === 'guess' && displayedGuess ? backgroundThumbnailUrl (displayedGuess) : null
const isWinningRunBackdrop =
!(guessThumbnail)
&& phase === 'question'
&& winningRunTargetPost != null
&& Boolean (backgroundThumbnailUrl (winningRunTargetPost))
const backdropMode =
guessThumbnail
? 'guess'
: isWinningRunBackdrop
? 'winning_run'
: 'normal'
const backdropMode = (() => {
if (guessThumbnail)
return 'guess'
if (isWinningRunBackdrop)
return 'winning_run'
return 'normal'
}) ()
const normalVisiblePosts = useMemo (
() => posts
@@ -2665,9 +2684,10 @@ const GekanatorBackdrop: FC<{
if (mode === 'winning_run' || mode === 'guess')
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
return motionMode === 'calm'
? { columns: 7, rows: 7, opacity: .14 }
: { columns: 10, rows: 10, opacity: .2 }
if (motionMode === 'calm')
return { columns: 7, rows: 7, opacity: .14 }
return { columns: 10, rows: 10, opacity: .2 }
},
[motionMode])
@@ -2723,10 +2743,12 @@ const GekanatorBackdrop: FC<{
?? directions[0],
[visualSeed, directions])
const marqueeDuration =
backdropMode === 'winning_run'
? motionMode === 'calm' ? 28 : 20
: motionMode === 'calm' ? 34 : 24
const marqueeDuration = (() => {
if (backdropMode === 'winning_run')
return motionMode === 'calm' ? 28 : 20
return motionMode === 'calm' ? 34 : 24
}) ()
const tileFlipDuration = motionMode === 'calm' ? .6 : .45
const x = useMotionValue (0)
const y = useMotionValue (0)
@@ -2944,6 +2966,13 @@ const GekanatorBackdrop: FC<{
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
to-pink-50 dark:from-red-950 dark:via-red-975 dark:to-red-900"/>)
const backdropTransition: Transition = (() => {
if (displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess')
return { duration: motionMode === 'calm' ? .95 : .75, ease: [.16, 1, .3, 1] }
return { duration: .2 }
}) ()
return (
<div className="fixed [inset:48px_0_0_0] z-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 flex items-center justify-center">
@@ -2958,11 +2987,7 @@ const GekanatorBackdrop: FC<{
animate={{ scale: renderedScale,
x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%',
y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }}
transition={(displayedBackdropMode === 'winning_run'
|| displayedBackdropMode === 'guess')
? { duration: motionMode === 'calm' ? .95 : .75,
ease: [.16, 1, .3, 1] }
: { duration: .2 }}>
transition={backdropTransition}>
{Array.from ({ length: 9 }, (_, duplicate) => {
const column = duplicate % 3
const row = Math.floor (duplicate / 3)
@@ -2988,20 +3013,24 @@ const GekanatorBackdrop: FC<{
index % Math.max (displayedThumbnails.length, 1)]
const frontThumbnail =
isFlippingTiles
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
: currentThumbnail
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
: currentThumbnail
const backThumbnail =
isFlippingTiles
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
: currentThumbnail
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
: currentThumbnail
const thumbnail =
displayedBackdropMode === 'winning_run'
|| displayedBackdropMode === 'guess'
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
: currentThumbnail
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
: currentThumbnail
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
return null
const imageSource = ['intro', 'end'].includes (phase) ? mascotAsset : thumbnail
const showStaticTile =
displayedBackdropMode !== 'normal' || !(isFlippingTiles)
return (
<motion.div
key={`${ duplicate }:${ index }`}
@@ -3009,16 +3038,14 @@ const GekanatorBackdrop: FC<{
layout={displayedBackdropMode !== 'normal'}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
style={{ perspective: 1600 }}>
{(displayedBackdropMode !== 'normal' || !(isFlippingTiles))
? (
{showStaticTile && (
<img
src={['intro', 'end'].includes (phase)
? mascotAsset
: thumbnail}
src={imageSource}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: renderedSettings.opacity }}/>)
: (
}
{!(showStaticTile) && (
<motion.div
className="absolute inset-0"
initial={{ rotateY: 0 }}
@@ -3095,9 +3122,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
(storedGame?.askedQuestionBank?.length ?? 0) > 0
? []
: storedGame?.askedQuestionBankIds ?? [])
() => {
if ((storedGame?.askedQuestionBank?.length ?? 0) > 0)
return []
return storedGame?.askedQuestionBankIds ?? []
})
const [search, setSearch] = useState (storedGame?.search ?? '')
const [selectingCorrectPost, setSelectingCorrectPost] = useState (
storedGame?.selectingCorrectPost ?? false)
@@ -3397,9 +3427,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
userPriorWeights, materialIndex, acceptedQuestionMatchIndex,
lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount])
const winningRunTargetPost = useMemo (
() => questionPlan.winningRunTargetId == null
? null
: posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null,
() => {
if (questionPlan.winningRunTargetId == null)
return null
return posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null
},
[posts, questionPlan.winningRunTargetId])
const winningRunQuestionsAsked = winningRunQuestionCount (
answers,
@@ -3420,21 +3453,21 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
[eligiblePosts, scores])
const currentQuestion = questionPlan.question
const answerPreviews = useMemo (
() => isAdmin && currentQuestion
? answerOptions.map (option => previewAnswer ({
posts: eligiblePosts,
scores,
question: currentQuestion,
answer: option.value,
materialIndex,
matchIndex: acceptedQuestionMatchIndex }))
: [],
() => {
if (!(isAdmin) || !(currentQuestion))
return []
return answerOptions.map (option => previewAnswer ({
posts: eligiblePosts,
scores,
question: currentQuestion,
answer: option.value,
materialIndex,
matchIndex: acceptedQuestionMatchIndex }))
},
[isAdmin, currentQuestion, eligiblePosts, materialIndex,
acceptedQuestionMatchIndex, scores])
const guessablePosts =
eligiblePosts.length > 0
? eligiblePosts
: availablePosts
const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts
const guessConfidences = useMemo (
() => confidencesFor (guessablePosts, scores),
[guessablePosts, scores])
@@ -3446,17 +3479,22 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
posts.find (post => post.id === reviewGuessedPostId) ?? null
const reviewCorrectPost =
posts.find (post => post.id === reviewCorrectPostId) ?? null
const effectiveResultWon =
resultWon
?? ((reviewGuessedPostId != null && reviewCorrectPostId != null)
? reviewGuessedPostId === reviewCorrectPostId
: null)
const effectiveBackgroundMotionMode =
backgroundMotionMode === 'off'
? 'off'
: (prefersReducedMotion
? 'calm'
: backgroundMotionMode)
const effectiveResultWon = (() => {
if (resultWon != null)
return resultWon
if (reviewGuessedPostId == null || reviewCorrectPostId == null)
return null
return reviewGuessedPostId === reviewCorrectPostId
}) ()
const effectiveBackgroundMotionMode = (() => {
if (backgroundMotionMode === 'off')
return 'off'
if (prefersReducedMotion)
return 'calm'
return backgroundMotionMode
}) ()
const backgroundPosts = useMemo (
() => backgroundPostsFor ({
phase,
@@ -3584,10 +3622,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
let recoveredStepCount = nextRecoveryStepCount
const nextAskedQuestionById =
new Map (nextAskedQuestionBank.map (question => [question.id, question]))
const answerCountAtRecovery =
allowPreQuestionRecovery
? nextAnswers.length
: Math.max (nextAnswers.length - 1, 0)
const answerCountAtRecovery = (() => {
if (allowPreQuestionRecovery)
return nextAnswers.length
return Math.max (nextAnswers.length - 1, 0)
}) ()
let recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
@@ -3863,12 +3903,14 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
}
const finishGame = (correctPostId: number) => {
const guessedPostId =
phase === 'end' || phase === 'review'
? reviewGuessedPostId
: phase === 'continue'
? lastRejectedGuessId ?? displayedGuess?.id
: displayedGuess?.id ?? lastRejectedGuessId
const guessedPostId = (() => {
if (phase === 'end' || phase === 'review')
return reviewGuessedPostId
if (phase === 'continue')
return lastRejectedGuessId ?? displayedGuess?.id
return displayedGuess?.id ?? lastRejectedGuessId
}) ()
if (!(guessedPostId))
return
@@ -4198,13 +4240,18 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue
const dialogue = phase === 'learned' ? resultDialogue : introDialogue
const saveStatusMessage =
saved
&& learnedExampleCount != null
? learnedExampleCount > 0
? `${ learnedExampleCount }件の回答を学習しました`
: null
: null
const introLoadingMessage =
phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……'
const questionSuggestionTitle =
questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。'
const saveStatusMessage = (() => {
if (!(saved) || learnedExampleCount == null)
return null
if (learnedExampleCount <= 0)
return null
return `${ learnedExampleCount }件の回答を学習しました`
}) ()
const introLoading = isLoading || acceptedQuestionsLoading
const readyToStart =
@@ -4323,18 +4370,23 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
</span>
{[{ mode: 'off' as const, label: 'オフ' },
{ mode: 'on' as const, label: 'オン' }]
.map (({ mode, label }) => (
<button
key={mode}
type="button"
className={cn (
'rounded-full px-2.5 py-1 transition-colors',
backgroundMotionMode === mode
? 'bg-pink-600 text-white'
: 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900')}
onClick={() => setBackgroundMotionMode (mode)}>
{label}
</button>))}
.map (({ mode, label }) => {
const modeClass =
backgroundMotionMode === mode
? 'bg-pink-600 text-white'
: 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900'
return (
<button
key={mode}
type="button"
className={cn (
'rounded-full px-2.5 py-1 transition-colors',
modeClass)}
onClick={() => setBackgroundMotionMode (mode)}>
{label}
</button>)
})}
{prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && (
<span className="ml-2 text-[11px] text-neutral-500 dark:text-neutral-400">
@@ -4365,9 +4417,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
{introLoading && (
<p>
{phase === 'intro'
? '投稿を読み込んでいます……'
: '前回のグカネータ状態を復元しています……'}
{introLoadingMessage}
</p>)}
{(Boolean (error) || Boolean (acceptedQuestionsError))
&& <p></p>}
@@ -4602,9 +4652,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<div className="space-y-2">
<div className="font-bold">稿</div>
{reviewCorrectPost
? <PostMiniCard post={reviewCorrectPost}/>
: <p className="text-sm text-red-600">稿</p>}
{reviewCorrectPost && <PostMiniCard post={reviewCorrectPost}/>}
{!(reviewCorrectPost) && (
<p className="text-sm text-red-600">稿</p>)}
<button
type="button"
className="rounded border border-yellow-300 px-3 py-2
@@ -4700,9 +4750,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<div className="space-y-2">
<div className="font-bold">稿</div>
{reviewCorrectPost
? <PostMiniCard post={reviewCorrectPost}/>
: <p className="text-sm text-red-600">稿</p>}
{reviewCorrectPost && <PostMiniCard post={reviewCorrectPost}/>}
{!(reviewCorrectPost) && (
<p className="text-sm text-red-600">稿</p>)}
<button
type="button"
className="rounded border border-yellow-300 px-3 py-2
@@ -4806,14 +4856,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold">
{questionSuggestionEntryMode === 'search'
? 'まず既存質問を探してください。'
: '新しい質問を追加します。'}
</p>
<p className="text-xl font-bold">{questionSuggestionTitle}</p>
</div>
{questionSuggestionEntryMode === 'search'
? (
{questionSuggestionEntryMode === 'search' && (
<>
<label className="block space-y-2">
<span className="font-bold"></span>
@@ -4831,21 +4876,26 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<div className="space-y-2">
<div className="font-bold"></div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{searchableSuggestedQuestions.map (question => (
<button
key={question.id}
type="button"
className={cn (
'block w-full rounded border px-3 py-2 text-left',
questionSuggestionSelectedId === (question.recordId ?? null)
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900')}
onClick={() => {
setQuestionSuggestionSelectedId (question.recordId ?? null)
setQuestionSuggestion ('')
}}>
<div className="font-bold">{question.text}</div>
</button>))}
{searchableSuggestedQuestions.map (question => {
const questionClass =
questionSuggestionSelectedId === (question.recordId ?? null)
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900'
return (
<button
key={question.id}
type="button"
className={cn (
'block w-full rounded border px-3 py-2 text-left',
questionClass)}
onClick={() => {
setQuestionSuggestionSelectedId (question.recordId ?? null)
setQuestionSuggestion ('')
}}>
<div className="font-bold">{question.text}</div>
</button>)
})}
</div>
</div>)}
{selectedSuggestedQuestion && (
@@ -4873,7 +4923,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
</div>
</div>)}
</>)
: (
}
{questionSuggestionEntryMode === 'new' && (
<div className="space-y-2">
<div className="font-bold"></div>
<textarea
@@ -4975,18 +5026,24 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
</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>))}
{answerOptions.map (option => {
const optionClass =
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'
return (
<button
key={option.value}
type="button"
className={cn (
'rounded border px-3 py-2',
optionClass)}
onClick={() =>
answerExtraQuestion (question.id, option.value)}>
{option.label}
</button>)
})}
</div>
</div>))}
</div>)}