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