このコミットが含まれているのは:
2026-06-09 08:17:16 +09:00
コミット be5359eb84
8個のファイルの変更339行の追加51行の削除
+74 -23
ファイルの表示
@@ -1,5 +1,4 @@
import { apiPost } from '@/lib/api'
import { fetchPosts } from '@/lib/posts'
import { apiGet, apiPost } from '@/lib/api'
import type { Post } from '@/types'
@@ -13,6 +12,7 @@ export type GekanatorAnswerValue =
export type GekanatorAnswerLog = {
questionId: string
questionText: string
questionCondition?: GekanatorQuestionCondition
answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue }
@@ -22,10 +22,26 @@ export type GekanatorQuestionKind =
| 'title'
| 'original_date'
export type GekanatorQuestionCondition =
| { type: 'tag'; key: string }
| { type: 'source'; host: string }
| { type: 'original-year'; year: number }
| { type: 'original-month'; month: number }
| { type: 'original-month-day'; monthDay: string }
| { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' }
export type StoredGekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition }
export type GekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
test: (post: Post) => boolean }
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
@@ -144,27 +160,49 @@ const questionableTag = (post: Post, key: string): boolean => {
}
const questionMatches = (
post: Post,
condition: GekanatorQuestionCondition,
): boolean => {
switch (condition.type)
{
case 'tag':
return questionableTag (post, condition.key)
case 'source':
return hostOf (post) === condition.host
case 'original-year':
return originalYearOf (post) === condition.year
case 'original-month':
return originalMonthOf (post) === condition.month
case 'original-month-day':
return originalMonthDayOf (post) === condition.monthDay
case 'title-length-greater-than':
return (post.title?.length ?? 0) > condition.length
case 'title-has-ascii':
return /[A-Za-z0-9]/.test (post.title ?? '')
}
}
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => ({
...question,
test: (post: Post) => questionMatches (post, question.condition) })
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
id: question.id,
text: question.text,
kind: question.kind,
condition: question.condition })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const limit = 200
const first = await fetchPosts ({
url: '', title: '', tags: '', match: 'all',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
page: 1, limit, order: 'original_created_at:desc' })
const posts = [...first.posts]
const totalPages = Math.ceil (first.count / limit)
for (let page = 2; page <= totalPages; page++)
{
const data = await fetchPosts ({
url: '', title: '', tags: '', match: 'all',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
page, limit, order: 'original_created_at:desc' })
posts.push (...data.posts)
}
return posts
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
return data.posts
}
@@ -209,6 +247,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `tag:${ key }`,
text: tagQuestionText (category, label),
kind: 'tag' as const,
condition: { type: 'tag' as const, key: String (key) },
test: (post: Post) => questionableTag (post, String (key)) }
})
@@ -219,6 +258,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
condition: { type: 'source' as const, host },
test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears)
@@ -228,6 +268,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `original-year:${ year }`,
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
condition: { type: 'original-year' as const, year },
test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths)
@@ -237,6 +278,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `original-month:${ month }`,
text: `オリジナルの投稿月は ${ month } 月?`,
kind: 'original_date' as const,
condition: { type: 'original-month' as const, month },
test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
@@ -249,6 +291,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: `original-month-day:${ monthDay }`,
text: `オリジナルの投稿日は ${ month }${ day } 日?`,
kind: 'original_date' as const,
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
test: (post: Post) => originalMonthDayOf (post) === monthDay }
})
@@ -257,11 +300,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
id: 'title:long',
text: '題名が長めの投稿?',
kind: 'title' as const,
condition: {
type: 'title-length-greater-than' as const,
length: titleLengthMedian },
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
condition: { type: 'title-has-ascii' as const },
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
@@ -294,6 +341,7 @@ export const saveGekanatorGame = async ({
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
question_condition: answer.questionCondition ?? null,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
@@ -301,10 +349,13 @@ export const saveGekanatorGame = async ({
export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId,
questionText,
answer,
}: {
gekanatorGameId: number
questionText: string
answer: GekanatorAnswerValue
}): Promise<{ id: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
question_text: questionText })
question_text: questionText,
answer })
+207 -26
ファイルの表示
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
@@ -7,8 +7,10 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
fetchGekanatorPosts,
restoreGekanatorQuestion,
saveGekanatorGame,
saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion } from '@/lib/gekanator'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -16,7 +18,8 @@ import type { FC } from 'react'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion } from '@/lib/gekanator'
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
type Phase =
@@ -61,6 +64,28 @@ type GameSnapshot = {
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
questionSuggestion: string
questionSuggestionAnswer: GekanatorAnswerValue }
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
{ label: 'いいえ', value: 'no' },
@@ -78,6 +103,39 @@ const runnerUpMaxPercent = .5
const hardMaxQuestions = 80
const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const clearStoredGame = (): void => {
try
{
sessionStorage.removeItem (gameStorageKey)
}
catch
{
return
}
}
const loadStoredGame = (): StoredGekanatorGame | null => {
try
{
const raw = sessionStorage.getItem (gameStorageKey)
if (!(raw))
return null
return JSON.parse (raw) as StoredGekanatorGame
}
catch
{
clearStoredGame ()
return null
}
}
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
@@ -442,30 +500,124 @@ const expectedAnswerFor = (
const GekanatorPage: FC = () => {
const [phase, setPhase] = useState<Phase> ('intro')
const [scores, setScores] = useState<Map<number, number>> (new Map ())
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> ([])
const [search, setSearch] = useState ('')
const [selectingCorrectPost, setSelectingCorrectPost] = useState (false)
const [saved, setSaved] = useState (false)
const [resultWon, setResultWon] = useState<boolean | null> (null)
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
const [savedGameId, setSavedGameId] = useState<number | null> (null)
const [questionSuggestion, setQuestionSuggestion] = useState ('')
const storedGame = useMemo (loadStoredGame, [])
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 [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({
queryKey: gekanatorKeys.posts (),
queryFn: fetchGekanatorPosts })
useEffect (() => {
if (posts.length === 0 || storedAskedQuestionBankIds.length === 0)
return
const questionById = new Map (
buildGekanatorQuestions (posts).map (question => [question.id, question]))
setAskedQuestionBank (
storedAskedQuestionBankIds
.map (questionId => questionById.get (questionId))
.filter ((question): question is GekanatorQuestion => question !== undefined))
setStoredAskedQuestionBankIds ([])
}, [posts, storedAskedQuestionBankIds])
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,
questionSuggestion,
questionSuggestionAnswer }
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,
questionSuggestion,
questionSuggestionAnswer])
const eligiblePosts = useMemo (
() => candidatePostsFor ({
posts,
@@ -531,10 +683,12 @@ const GekanatorPage: FC = () => {
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
reset ()
}})
const reset = () => {
clearStoredGame ()
saveMutation.reset ()
setPhase ('intro')
setScores (new Map ())
@@ -554,6 +708,7 @@ const GekanatorPage: FC = () => {
setReviewCorrectPostId (null)
setSavedGameId (null)
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
setHistory ([])
}
@@ -659,6 +814,7 @@ const GekanatorPage: FC = () => {
const nextAnswers = [...answers, {
questionId: currentQuestion.id,
questionText: currentQuestion.text,
questionCondition: currentQuestion.condition,
answer: value,
originalAnswer: value }]
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
@@ -784,7 +940,10 @@ const GekanatorPage: FC = () => {
return
saveReviewedResult (gekanatorGameId => {
questionSuggestionMutation.mutate ({ gekanatorGameId, questionText })
questionSuggestionMutation.mutate ({
gekanatorGameId,
questionText,
answer: questionSuggestionAnswer })
})
}
@@ -936,7 +1095,12 @@ const GekanatorPage: FC = () => {
{dialogue}
</p>
{isLoading && <p>稿...</p>}
{isLoading && (
<p>
{phase === 'intro'
? '投稿を読み込んでゐます...'
: '前回のグカネータ状態を復元してゐます...'}
</p>)}
{Boolean (error) && <p>稿</p>}
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
@@ -1008,7 +1172,7 @@ const GekanatorPage: FC = () => {
</div>
</div>)}
{phase === 'question' && !(currentQuestion) && (
{!(isLoading) && phase === 'question' && !(currentQuestion) && (
<div className="space-y-4">
<p className="text-xl font-bold">
@@ -1157,7 +1321,8 @@ const GekanatorPage: FC = () => {
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}
disabled={saveMutation.isPending
|| questionSuggestionMutation.isPending}
onClick={() => setPhase ('question_suggestion')}>
</button>
@@ -1290,13 +1455,29 @@ const GekanatorPage: FC = () => {
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}
disabled={saveMutation.isPending
|| questionSuggestionMutation.isPending}
onClick={() => setPhase ('end')}>
</button>