グカネータ作成 / ウィニング・ラン修正 (#41) #366
@@ -0,0 +1,19 @@
|
|||||||
|
class GekanatorQuestionSuggestionsController < ApplicationController
|
||||||
|
def create
|
||||||
|
return head :not_found unless current_user&.admin?
|
||||||
|
|
||||||
|
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
|
||||||
|
return head :not_found unless game
|
||||||
|
|
||||||
|
suggestion = GekanatorQuestionSuggestion.new(
|
||||||
|
gekanator_game: game,
|
||||||
|
user: current_user,
|
||||||
|
question_text: params.require(:question_text))
|
||||||
|
|
||||||
|
if suggestion.save
|
||||||
|
render json: { id: suggestion.id }, status: :created
|
||||||
|
else
|
||||||
|
render_validation_error suggestion
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,9 @@ class GekanatorGame < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :guessed_post, class_name: 'Post'
|
belongs_to :guessed_post, class_name: 'Post'
|
||||||
belongs_to :correct_post, class_name: 'Post'
|
belongs_to :correct_post, class_name: 'Post'
|
||||||
|
has_many :question_suggestions,
|
||||||
|
class_name: 'GekanatorQuestionSuggestion',
|
||||||
|
dependent: :delete_all
|
||||||
|
|
||||||
validates :answers, presence: true
|
validates :answers, presence: true
|
||||||
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||||
|
MAX_QUESTIONS_PER_GAME = 1
|
||||||
|
|
||||||
|
belongs_to :gekanator_game
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
validates :question_text, presence: true, length: { maximum: 1000 }
|
||||||
|
validates :processed, inclusion: { in: [true, false] }
|
||||||
|
validate :question_suggestion_limit_per_game, on: :create
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def question_suggestion_limit_per_game
|
||||||
|
return if gekanator_game_id.blank?
|
||||||
|
|
||||||
|
count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count
|
||||||
|
if count >= MAX_QUESTIONS_PER_GAME
|
||||||
|
errors.add(:base, '質問追加数を超えてゐます.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -65,6 +65,9 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
namespace :gekanator do
|
namespace :gekanator do
|
||||||
resources :games, only: [:create], controller: '/gekanator_games'
|
resources :games, only: [:create], controller: '/gekanator_games'
|
||||||
|
resources :question_suggestions,
|
||||||
|
only: [:create],
|
||||||
|
controller: '/gekanator_question_suggestions'
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :users, only: [:create, :update] do
|
resources :users, only: [:create, :update] do
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_question_suggestions do |t|
|
||||||
|
t.references :gekanator_game,
|
||||||
|
null: false,
|
||||||
|
foreign_key: { on_delete: :cascade }
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.text :question_text, null: false
|
||||||
|
t.boolean :processed, null: false, default: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+14
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -63,6 +63,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
|||||||
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "gekanator_game_id", null: false
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.text "question_text", null: false
|
||||||
|
t.boolean "processed", default: false, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
|
||||||
|
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.binary "ip_address", limit: 16, null: false
|
t.binary "ip_address", limit: 16, null: false
|
||||||
t.datetime "banned_at"
|
t.datetime "banned_at"
|
||||||
@@ -496,6 +507,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
|||||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||||
add_foreign_key "gekanator_games", "users"
|
add_foreign_key "gekanator_games", "users"
|
||||||
|
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
||||||
|
add_foreign_key "gekanator_question_suggestions", "users"
|
||||||
add_foreign_key "material_versions", "materials"
|
add_foreign_key "material_versions", "materials"
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
add_foreign_key "material_versions", "tags"
|
add_foreign_key "material_versions", "tags"
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export type GekanatorAnswerValue =
|
|||||||
export type GekanatorAnswerLog = {
|
export type GekanatorAnswerLog = {
|
||||||
questionId: string
|
questionId: string
|
||||||
questionText: string
|
questionText: string
|
||||||
answer: GekanatorAnswerValue }
|
answer: GekanatorAnswerValue
|
||||||
|
originalAnswer: GekanatorAnswerValue }
|
||||||
|
|
||||||
export type GekanatorQuestionKind =
|
export type GekanatorQuestionKind =
|
||||||
| 'tag'
|
| 'tag'
|
||||||
@@ -293,4 +294,17 @@ export const saveGekanatorGame = async ({
|
|||||||
answers: answers.map (answer => ({
|
answers: answers.map (answer => ({
|
||||||
question_id: answer.questionId,
|
question_id: answer.questionId,
|
||||||
question_text: answer.questionText,
|
question_text: answer.questionText,
|
||||||
answer: answer.answer })) })
|
answer: answer.answer,
|
||||||
|
original_answer: answer.originalAnswer })) })
|
||||||
|
|
||||||
|
|
||||||
|
export const saveGekanatorQuestionSuggestion = async ({
|
||||||
|
gekanatorGameId,
|
||||||
|
questionText,
|
||||||
|
}: {
|
||||||
|
gekanatorGameId: number
|
||||||
|
questionText: string
|
||||||
|
}): Promise<{ id: number }> =>
|
||||||
|
await apiPost ('/gekanator/question_suggestions', {
|
||||||
|
gekanator_game_id: gekanatorGameId,
|
||||||
|
question_text: questionText })
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { buildGekanatorQuestions,
|
import { buildGekanatorQuestions,
|
||||||
fetchGekanatorPosts,
|
fetchGekanatorPosts,
|
||||||
saveGekanatorGame } from '@/lib/gekanator'
|
saveGekanatorGame,
|
||||||
|
saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
|
||||||
import { gekanatorKeys } from '@/lib/queryKeys'
|
import { gekanatorKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type Phase =
|
|||||||
| 'continue'
|
| 'continue'
|
||||||
| 'end'
|
| 'end'
|
||||||
| 'review'
|
| 'review'
|
||||||
|
| 'question_suggestion'
|
||||||
| 'learned'
|
| 'learned'
|
||||||
|
|
||||||
type AnswerOption = {
|
type AnswerOption = {
|
||||||
@@ -66,6 +68,9 @@ const answerOptions: AnswerOption[] = [
|
|||||||
{ label: 'たぶんいいえ', value: 'probably_no' },
|
{ label: 'たぶんいいえ', value: 'probably_no' },
|
||||||
{ label: 'わからない', value: 'unknown' }]
|
{ label: 'わからない', value: 'unknown' }]
|
||||||
|
|
||||||
|
const answerLabelFor = (value: GekanatorAnswerValue): string =>
|
||||||
|
answerOptions.find (option => option.value === value)?.label ?? value
|
||||||
|
|
||||||
const questionsBetweenGuesses = 25
|
const questionsBetweenGuesses = 25
|
||||||
const minQuestionsBeforeCertainGuess = 5
|
const minQuestionsBeforeCertainGuess = 5
|
||||||
const certainGuessPercent = 99.5
|
const certainGuessPercent = 99.5
|
||||||
@@ -313,10 +318,14 @@ const chooseQuestion = ({
|
|||||||
const scoredPosts = posts
|
const scoredPosts = posts
|
||||||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
.sort ((a, b) => b.score - a.score)
|
.sort ((a, b) => b.score - a.score)
|
||||||
const topScore = scoredPosts[0]?.score ?? 0
|
const maxScore = scoredPosts[0]?.score ?? 0
|
||||||
const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2)
|
const weightedPosts = scoredPosts.map (item => ({
|
||||||
const focusCandidates =
|
...item,
|
||||||
nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : []
|
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 = (
|
const signatureFor = (
|
||||||
question: GekanatorQuestion,
|
question: GekanatorQuestion,
|
||||||
@@ -344,7 +353,7 @@ const chooseQuestion = ({
|
|||||||
const rank = (
|
const rank = (
|
||||||
questionsToRank: GekanatorQuestion[],
|
questionsToRank: GekanatorQuestion[],
|
||||||
candidates: { post: Post; score: number }[],
|
candidates: { post: Post; score: number }[],
|
||||||
focusCandidates: { post: Post; score: number }[],
|
weightedCandidates: { post: Post; score: number; weight: number }[],
|
||||||
) => {
|
) => {
|
||||||
const redundant = redundantSignatures (candidates)
|
const redundant = redundantSignatures (candidates)
|
||||||
const nonTagCount =
|
const nonTagCount =
|
||||||
@@ -361,49 +370,36 @@ const chooseQuestion = ({
|
|||||||
if (yes === 0 || no === 0)
|
if (yes === 0 || no === 0)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const focusSignature = signatureFor (question, focusCandidates)
|
const yesWeight = weightedCandidates.reduce (
|
||||||
const focusYes = focusSignature.split ('').filter (value => value === '1').length
|
(sum, item) => sum + (question.test (item.post) ? item.weight : 0),
|
||||||
const focusNo = focusCandidates.length - focusYes
|
0)
|
||||||
const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0
|
const noWeight = 1 - yesWeight
|
||||||
|
if (yesWeight <= 0 || noWeight <= 0)
|
||||||
|
return null
|
||||||
|
|
||||||
const splitScore = Math.abs (candidates.length / 2 - yes)
|
const weightedSplitScore = Math.abs (.5 - yesWeight)
|
||||||
const focusSplitScore = focusCandidates.length >= 2
|
const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
|
||||||
? Math.abs (focusCandidates.length / 2 - focusYes)
|
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
|
||||||
: 0
|
|
||||||
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0
|
|
||||||
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||||||
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
|
||||||
const focusPenalty =
|
|
||||||
focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0
|
|
||||||
|
|
||||||
return { question,
|
return { question,
|
||||||
score: focusPenalty
|
score: weightedSplitScore * 100
|
||||||
+ focusSplitScore * 20
|
+ unweightedSplitScore * 8
|
||||||
+ splitScore
|
|
||||||
+ tagPenalty
|
+ tagPenalty
|
||||||
+ narrowPenalty,
|
+ narrowPenalty,
|
||||||
narrow: narrowPenalty > 0,
|
narrow: narrowPenalty > 0 }
|
||||||
separatesFocus }
|
|
||||||
})
|
})
|
||||||
.filter ((item): item is {
|
.filter ((item): item is {
|
||||||
question: GekanatorQuestion
|
question: GekanatorQuestion
|
||||||
score: number
|
score: number
|
||||||
narrow: boolean
|
narrow: boolean } => item !== null && Number.isFinite (item.score))
|
||||||
separatesFocus: boolean } => item !== null && Number.isFinite (item.score))
|
|
||||||
.sort ((a, b) => a.score - b.score)
|
.sort ((a, b) => a.score - b.score)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unansweredQuestions =
|
const unansweredQuestions =
|
||||||
questions.filter (question => !(askedIds.has (question.id)))
|
questions.filter (question => !(askedIds.has (question.id)))
|
||||||
const ranked = rank (unansweredQuestions, scoredPosts, focusCandidates)
|
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
|
||||||
|
|
||||||
if (focusCandidates.length >= 2)
|
|
||||||
return (
|
|
||||||
ranked.find (item => item.separatesFocus && !(item.narrow))
|
|
||||||
?? ranked.find (item => item.separatesFocus)
|
|
||||||
?? ranked.find (item => !(item.narrow))
|
|
||||||
?? ranked[0]
|
|
||||||
)?.question ?? null
|
|
||||||
|
|
||||||
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
|
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
|
||||||
}
|
}
|
||||||
@@ -434,6 +430,17 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
|||||||
</div>)
|
</div>)
|
||||||
|
|
||||||
|
|
||||||
|
const expectedAnswerFor = (
|
||||||
|
question: GekanatorQuestion | undefined,
|
||||||
|
correctPost: Post | null,
|
||||||
|
): GekanatorAnswerValue | null => {
|
||||||
|
if (!(question) || !(correctPost))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return question.test (correctPost) ? 'yes' : 'no'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const GekanatorPage: FC = () => {
|
const GekanatorPage: FC = () => {
|
||||||
const [phase, setPhase] = useState<Phase> ('intro')
|
const [phase, setPhase] = useState<Phase> ('intro')
|
||||||
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
||||||
@@ -451,6 +458,8 @@ const GekanatorPage: FC = () => {
|
|||||||
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
||||||
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
|
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
|
||||||
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
|
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
|
||||||
|
const [savedGameId, setSavedGameId] = useState<number | null> (null)
|
||||||
|
const [questionSuggestion, setQuestionSuggestion] = useState ('')
|
||||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||||
|
|
||||||
const { data: posts = [], isLoading, error } = useQuery ({
|
const { data: posts = [], isLoading, error } = useQuery ({
|
||||||
@@ -471,6 +480,9 @@ const GekanatorPage: FC = () => {
|
|||||||
const scoringQuestions = useMemo (() => {
|
const scoringQuestions = useMemo (() => {
|
||||||
return mergeQuestions ([...questions, ...askedQuestionBank])
|
return mergeQuestions ([...questions, ...askedQuestionBank])
|
||||||
}, [questions, askedQuestionBank])
|
}, [questions, askedQuestionBank])
|
||||||
|
const scoringQuestionById = useMemo (
|
||||||
|
() => new Map (scoringQuestions.map (question => [question.id, question])),
|
||||||
|
[scoringQuestions])
|
||||||
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
|
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
|
||||||
const nonRejectedPosts = useMemo (
|
const nonRejectedPosts = useMemo (
|
||||||
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
|
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
|
||||||
@@ -510,10 +522,16 @@ const GekanatorPage: FC = () => {
|
|||||||
posts.find (post => post.id === reviewCorrectPostId) ?? null
|
posts.find (post => post.id === reviewCorrectPostId) ?? null
|
||||||
const saveMutation = useMutation ({
|
const saveMutation = useMutation ({
|
||||||
mutationFn: saveGekanatorGame,
|
mutationFn: saveGekanatorGame,
|
||||||
onSuccess: () => {
|
onSuccess: (data, variables) => {
|
||||||
setSaved (true)
|
setSaved (true)
|
||||||
setResultWon (reviewGuessedPostId === reviewCorrectPostId)
|
setSavedGameId (data.id)
|
||||||
setPhase ('learned')
|
setResultWon (variables.guessedPostId === variables.correctPostId)
|
||||||
|
}})
|
||||||
|
const questionSuggestionMutation = useMutation ({
|
||||||
|
mutationFn: saveGekanatorQuestionSuggestion,
|
||||||
|
onSuccess: () => {
|
||||||
|
setQuestionSuggestion ('')
|
||||||
|
reset ()
|
||||||
}})
|
}})
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
@@ -534,6 +552,8 @@ const GekanatorPage: FC = () => {
|
|||||||
setActiveGuessId (null)
|
setActiveGuessId (null)
|
||||||
setReviewGuessedPostId (null)
|
setReviewGuessedPostId (null)
|
||||||
setReviewCorrectPostId (null)
|
setReviewCorrectPostId (null)
|
||||||
|
setSavedGameId (null)
|
||||||
|
setQuestionSuggestion ('')
|
||||||
setHistory ([])
|
setHistory ([])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +659,8 @@ const GekanatorPage: FC = () => {
|
|||||||
const nextAnswers = [...answers, {
|
const nextAnswers = [...answers, {
|
||||||
questionId: currentQuestion.id,
|
questionId: currentQuestion.id,
|
||||||
questionText: currentQuestion.text,
|
questionText: currentQuestion.text,
|
||||||
answer: value }]
|
answer: value,
|
||||||
|
originalAnswer: value }]
|
||||||
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||||||
const nextAskedQuestionBank = [
|
const nextAskedQuestionBank = [
|
||||||
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
|
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
|
||||||
@@ -705,6 +726,9 @@ const GekanatorPage: FC = () => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
saveMutation.reset ()
|
saveMutation.reset ()
|
||||||
|
questionSuggestionMutation.reset ()
|
||||||
|
setSaved (false)
|
||||||
|
setSavedGameId (null)
|
||||||
setReviewGuessedPostId (guessedPostId)
|
setReviewGuessedPostId (guessedPostId)
|
||||||
setReviewCorrectPostId (correctPostId)
|
setReviewCorrectPostId (correctPostId)
|
||||||
setSearch ('')
|
setSearch ('')
|
||||||
@@ -717,24 +741,51 @@ const GekanatorPage: FC = () => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
saveMutation.reset ()
|
saveMutation.reset ()
|
||||||
|
questionSuggestionMutation.reset ()
|
||||||
|
setSaved (false)
|
||||||
|
setSavedGameId (null)
|
||||||
setSelectingCorrectPost (false)
|
setSelectingCorrectPost (false)
|
||||||
setSearch ('')
|
setSearch ('')
|
||||||
setPhase ('review')
|
setPhase ('review')
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveReviewedResult = () => {
|
const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
|
||||||
if (
|
if (
|
||||||
reviewGuessedPostId === null
|
reviewGuessedPostId === null
|
||||||
|| reviewCorrectPostId === null
|
|| reviewCorrectPostId === null
|
||||||
|| saveMutation.isPending
|
|| saveMutation.isPending
|
||||||
|| saved
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if (savedGameId !== null)
|
||||||
|
{
|
||||||
|
onSuccess (savedGameId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saveMutation.mutate ({
|
saveMutation.mutate ({
|
||||||
guessedPostId: reviewGuessedPostId,
|
guessedPostId: reviewGuessedPostId,
|
||||||
correctPostId: reviewCorrectPostId,
|
correctPostId: reviewCorrectPostId,
|
||||||
answers })
|
answers },
|
||||||
|
{ onSuccess: data => onSuccess (data.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAndReset = () => {
|
||||||
|
saveReviewedResult (reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAndLearn = () => {
|
||||||
|
saveReviewedResult (() => setPhase ('learned'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitQuestionSuggestion = () => {
|
||||||
|
const questionText = questionSuggestion.trim ()
|
||||||
|
if (!(questionText) || questionSuggestionMutation.isPending)
|
||||||
|
return
|
||||||
|
|
||||||
|
saveReviewedResult (gekanatorGameId => {
|
||||||
|
questionSuggestionMutation.mutate ({ gekanatorGameId, questionText })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectGuess = () => {
|
const rejectGuess = () => {
|
||||||
@@ -811,6 +862,8 @@ const GekanatorPage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
||||||
|
setSaved (false)
|
||||||
|
setSavedGameId (null)
|
||||||
setAnswers (answers.map ((answer, i) =>
|
setAnswers (answers.map ((answer, i) =>
|
||||||
i === index ? { ...answer, answer: value } : answer))
|
i === index ? { ...answer, answer: value } : answer))
|
||||||
}
|
}
|
||||||
@@ -818,6 +871,8 @@ const GekanatorPage: FC = () => {
|
|||||||
const selectCorrectPost = (post: Post) => {
|
const selectCorrectPost = (post: Post) => {
|
||||||
if (phase === 'review')
|
if (phase === 'review')
|
||||||
{
|
{
|
||||||
|
setSaved (false)
|
||||||
|
setSavedGameId (null)
|
||||||
setReviewCorrectPostId (post.id)
|
setReviewCorrectPostId (post.id)
|
||||||
setSelectingCorrectPost (false)
|
setSelectingCorrectPost (false)
|
||||||
setSearch ('')
|
setSearch ('')
|
||||||
@@ -1006,7 +1061,7 @@ const GekanatorPage: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
学習ログの保存に失敗しました。もう一度試せます。
|
記録できませんでした。通信状態を確認してもう一度試して。
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
@@ -1045,7 +1100,7 @@ const GekanatorPage: FC = () => {
|
|||||||
<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>
|
<p className="text-xl font-bold">グカカカカwwwww</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reviewGuessedPost && (
|
{reviewGuessedPost && (
|
||||||
@@ -1076,7 +1131,7 @@ const GekanatorPage: FC = () => {
|
|||||||
|
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
学習ログの保存に失敗しました。もう一度試せます。
|
記録できませんでした。通信状態を確認してもう一度試して。
|
||||||
</p>)}
|
</p>)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -1084,20 +1139,27 @@ const GekanatorPage: FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
hover:bg-pink-500 disabled:opacity-50"
|
hover:bg-pink-500 disabled:opacity-50"
|
||||||
disabled={
|
disabled={reviewCorrectPostId === null || saveMutation.isPending}
|
||||||
reviewCorrectPostId === null || saveMutation.isPending || saved
|
onClick={saveAndReset}>
|
||||||
}
|
もう一度
|
||||||
onClick={saveReviewedResult}>
|
|
||||||
保存
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded border border-yellow-300 px-4 py-2
|
className="rounded border border-yellow-300 px-4 py-2
|
||||||
hover:bg-yellow-100 dark:border-red-700
|
hover:bg-yellow-100 dark:border-red-700
|
||||||
dark:hover:bg-red-900"
|
dark:hover:bg-red-900"
|
||||||
disabled={reviewCorrectPostId === null || saveMutation.isPending || saved}
|
disabled={reviewCorrectPostId === null || saveMutation.isPending}
|
||||||
onClick={startReview}>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -1133,7 +1195,12 @@ const GekanatorPage: FC = () => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-bold">質問と回答</div>
|
<div className="font-bold">質問と回答</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{answers.map ((answer, index) => (
|
{answers.map ((answer, index) => {
|
||||||
|
const expectedAnswer = expectedAnswerFor (
|
||||||
|
scoringQuestionById.get (answer.questionId),
|
||||||
|
reviewCorrectPost)
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${ answer.questionId }:${ index }`}
|
key={`${ answer.questionId }:${ index }`}
|
||||||
className="rounded border border-yellow-100 p-3
|
className="rounded border border-yellow-100 p-3
|
||||||
@@ -1142,9 +1209,21 @@ const GekanatorPage: FC = () => {
|
|||||||
質問 {index + 1}
|
質問 {index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-bold">{answer.questionText}</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
|
<select
|
||||||
value={answer.answer}
|
value={answer.answer}
|
||||||
className="mt-2 rounded border border-yellow-300 bg-white px-2 py-1
|
className="rounded border border-yellow-300 bg-white px-2
|
||||||
|
py-1
|
||||||
dark:border-red-700 dark:bg-red-950"
|
dark:border-red-700 dark:bg-red-950"
|
||||||
onChange={ev =>
|
onChange={ev =>
|
||||||
correctAnswerAt (
|
correctAnswerAt (
|
||||||
@@ -1155,7 +1234,10 @@ const GekanatorPage: FC = () => {
|
|||||||
{option.label}
|
{option.label}
|
||||||
</option>))}
|
</option>))}
|
||||||
</select>
|
</select>
|
||||||
</div>))}
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1166,7 +1248,7 @@ const GekanatorPage: FC = () => {
|
|||||||
|
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
学習ログの保存に失敗しました。もう一度試せます。
|
記録できませんでした。通信状態を確認してもう一度試して。
|
||||||
</p>)}
|
</p>)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -1175,10 +1257,12 @@ const GekanatorPage: FC = () => {
|
|||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
hover:bg-pink-500 disabled:opacity-50"
|
hover:bg-pink-500 disabled:opacity-50"
|
||||||
disabled={
|
disabled={
|
||||||
reviewCorrectPostId === null || saveMutation.isPending || saved
|
reviewCorrectPostId === null
|
||||||
|
|| saveMutation.isPending
|
||||||
|
|| questionSuggestionMutation.isPending
|
||||||
}
|
}
|
||||||
onClick={saveReviewedResult}>
|
onClick={saveAndLearn}>
|
||||||
保存
|
完了
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1191,6 +1275,51 @@ const GekanatorPage: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</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>
|
||||||
|
<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 bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500 disabled:opacity-50"
|
||||||
|
disabled={
|
||||||
|
reviewCorrectPostId === null
|
||||||
|
|| questionSuggestion.trim () === ''
|
||||||
|
|| saveMutation.isPending
|
||||||
|
|| questionSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={submitQuestionSuggestion}>
|
||||||
|
追加してもう一度
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(saveMutation.isError || questionSuggestionMutation.isError) && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
記録できませんでした。通信状態を確認してもう一度試して。
|
||||||
|
</p>)}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{phase === 'learned' && (
|
{phase === 'learned' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>覚えたよ.次はもっと見通す.</p>
|
<p>覚えたよ.次はもっと見通す.</p>
|
||||||
@@ -1233,7 +1362,7 @@ const GekanatorPage: FC = () => {
|
|||||||
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
||||||
{saveMutation.isError && (
|
{saveMutation.isError && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
学習ログの保存に失敗しました。もう一度試せます。
|
記録できませんでした。通信状態を確認してもう一度試して。
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
</section>)}
|
</section>)}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする