グカネータ / 質問パターン見直し (#41) #365
@@ -0,0 +1,46 @@
|
|||||||
|
class GekanatorPostsController < ApplicationController
|
||||||
|
def index
|
||||||
|
return head :not_found unless current_user&.admin?
|
||||||
|
|
||||||
|
posts =
|
||||||
|
Post
|
||||||
|
.preload(tags: :tag_name)
|
||||||
|
.with_attached_thumbnail
|
||||||
|
.order(Arel.sql(
|
||||||
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
||||||
|
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
||||||
|
|
||||||
|
render json: { posts: posts.map { |post| post_json(post) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def post_json post
|
||||||
|
{
|
||||||
|
id: post.id,
|
||||||
|
url: post.url,
|
||||||
|
title: post.title,
|
||||||
|
thumbnail: thumbnail_url(post),
|
||||||
|
thumbnail_base: post.thumbnail_base,
|
||||||
|
original_created_from: post.original_created_from,
|
||||||
|
original_created_before: post.original_created_before,
|
||||||
|
tags: post.tags.map { |tag| tag_json(tag) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_json tag
|
||||||
|
{
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail_url post
|
||||||
|
return nil unless post.thumbnail.attached?
|
||||||
|
|
||||||
|
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,7 +8,8 @@ class GekanatorQuestionSuggestionsController < ApplicationController
|
|||||||
suggestion = GekanatorQuestionSuggestion.new(
|
suggestion = GekanatorQuestionSuggestion.new(
|
||||||
gekanator_game: game,
|
gekanator_game: game,
|
||||||
user: current_user,
|
user: current_user,
|
||||||
question_text: params.require(:question_text))
|
question_text: params.require(:question_text),
|
||||||
|
answer: params.require(:answer))
|
||||||
|
|
||||||
if suggestion.save
|
if suggestion.save
|
||||||
render json: { id: suggestion.id }, status: :created
|
render json: { id: suggestion.id }, status: :created
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
class GekanatorQuestionSuggestion < ApplicationRecord
|
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||||
MAX_QUESTIONS_PER_GAME = 1
|
MAX_QUESTIONS_PER_GAME = 1
|
||||||
|
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
|
||||||
|
|
||||||
belongs_to :gekanator_game
|
belongs_to :gekanator_game
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
validates :question_text, presence: true, length: { maximum: 1000 }
|
validates :question_text, presence: true, length: { maximum: 1000 }
|
||||||
|
validates :answer, presence: true, inclusion: { in: ANSWERS }
|
||||||
validates :processed, inclusion: { in: [true, false] }
|
validates :processed, inclusion: { in: [true, false] }
|
||||||
validate :question_suggestion_limit_per_game, on: :create
|
validate :question_suggestion_limit_per_game, on: :create
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ 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 :posts, only: [:index], controller: '/gekanator_posts'
|
||||||
resources :question_suggestions,
|
resources :question_suggestions,
|
||||||
only: [:create],
|
only: [:create],
|
||||||
controller: '/gekanator_question_suggestions'
|
controller: '/gekanator_question_suggestions'
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :gekanator_question_suggestions, :answer, :string, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+2
-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_08_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_08_002000) 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
|
||||||
@@ -70,6 +70,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
|
|||||||
t.boolean "processed", default: false, null: false
|
t.boolean "processed", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "answer", null: false
|
||||||
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
|
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"
|
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|||||||
+72
-21
@@ -1,5 +1,4 @@
|
|||||||
import { apiPost } from '@/lib/api'
|
import { apiGet, apiPost } from '@/lib/api'
|
||||||
import { fetchPosts } from '@/lib/posts'
|
|
||||||
|
|
||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
@@ -13,6 +12,7 @@ export type GekanatorAnswerValue =
|
|||||||
export type GekanatorAnswerLog = {
|
export type GekanatorAnswerLog = {
|
||||||
questionId: string
|
questionId: string
|
||||||
questionText: string
|
questionText: string
|
||||||
|
questionCondition?: GekanatorQuestionCondition
|
||||||
answer: GekanatorAnswerValue
|
answer: GekanatorAnswerValue
|
||||||
originalAnswer: GekanatorAnswerValue }
|
originalAnswer: GekanatorAnswerValue }
|
||||||
|
|
||||||
@@ -22,10 +22,26 @@ export type GekanatorQuestionKind =
|
|||||||
| 'title'
|
| 'title'
|
||||||
| 'original_date'
|
| '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 = {
|
export type GekanatorQuestion = {
|
||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
kind: GekanatorQuestionKind
|
kind: GekanatorQuestionKind
|
||||||
|
condition: GekanatorQuestionCondition
|
||||||
test: (post: Post) => boolean }
|
test: (post: Post) => boolean }
|
||||||
|
|
||||||
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||||
@@ -144,27 +160,49 @@ const questionableTag = (post: Post, key: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
const questionMatches = (
|
||||||
const limit = 200
|
post: Post,
|
||||||
const first = await fetchPosts ({
|
condition: GekanatorQuestionCondition,
|
||||||
url: '', title: '', tags: '', match: 'all',
|
): boolean => {
|
||||||
originalCreatedFrom: '', originalCreatedTo: '',
|
switch (condition.type)
|
||||||
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 ({
|
case 'tag':
|
||||||
url: '', title: '', tags: '', match: 'all',
|
return questionableTag (post, condition.key)
|
||||||
originalCreatedFrom: '', originalCreatedTo: '',
|
case 'source':
|
||||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
return hostOf (post) === condition.host
|
||||||
page, limit, order: 'original_created_at:desc' })
|
case 'original-year':
|
||||||
posts.push (...data.posts)
|
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 ?? '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return posts
|
|
||||||
|
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 data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
|
||||||
|
return data.posts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -209,6 +247,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
id: `tag:${ key }`,
|
id: `tag:${ key }`,
|
||||||
text: tagQuestionText (category, label),
|
text: tagQuestionText (category, label),
|
||||||
kind: 'tag' as const,
|
kind: 'tag' as const,
|
||||||
|
condition: { type: 'tag' as const, key: String (key) },
|
||||||
test: (post: Post) => questionableTag (post, String (key)) }
|
test: (post: Post) => questionableTag (post, String (key)) }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,6 +258,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
id: `source:${ host }`,
|
id: `source:${ host }`,
|
||||||
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||||
kind: 'source' as const,
|
kind: 'source' as const,
|
||||||
|
condition: { type: 'source' as const, host },
|
||||||
test: (post: Post) => hostOf (post) === host }))
|
test: (post: Post) => hostOf (post) === host }))
|
||||||
|
|
||||||
const originalYearQuestions = usefulEntries (originalYears)
|
const originalYearQuestions = usefulEntries (originalYears)
|
||||||
@@ -228,6 +268,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
id: `original-year:${ year }`,
|
id: `original-year:${ year }`,
|
||||||
text: `オリジナルの投稿年は ${ year } 年?`,
|
text: `オリジナルの投稿年は ${ year } 年?`,
|
||||||
kind: 'original_date' as const,
|
kind: 'original_date' as const,
|
||||||
|
condition: { type: 'original-year' as const, year },
|
||||||
test: (post: Post) => originalYearOf (post) === year }))
|
test: (post: Post) => originalYearOf (post) === year }))
|
||||||
|
|
||||||
const originalMonthQuestions = usefulEntries (originalMonths)
|
const originalMonthQuestions = usefulEntries (originalMonths)
|
||||||
@@ -237,6 +278,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
id: `original-month:${ month }`,
|
id: `original-month:${ month }`,
|
||||||
text: `オリジナルの投稿月は ${ month } 月?`,
|
text: `オリジナルの投稿月は ${ month } 月?`,
|
||||||
kind: 'original_date' as const,
|
kind: 'original_date' as const,
|
||||||
|
condition: { type: 'original-month' as const, month },
|
||||||
test: (post: Post) => originalMonthOf (post) === month }))
|
test: (post: Post) => originalMonthOf (post) === month }))
|
||||||
|
|
||||||
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
|
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
|
||||||
@@ -249,6 +291,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
id: `original-month-day:${ monthDay }`,
|
id: `original-month-day:${ monthDay }`,
|
||||||
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
||||||
kind: 'original_date' as const,
|
kind: 'original_date' as const,
|
||||||
|
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
|
||||||
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
test: (post: Post) => originalMonthDayOf (post) === monthDay }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -257,11 +300,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
id: 'title:long',
|
id: 'title:long',
|
||||||
text: '題名が長めの投稿?',
|
text: '題名が長めの投稿?',
|
||||||
kind: 'title' as const,
|
kind: 'title' as const,
|
||||||
|
condition: {
|
||||||
|
type: 'title-length-greater-than' as const,
|
||||||
|
length: titleLengthMedian },
|
||||||
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
||||||
{
|
{
|
||||||
id: 'title:ascii',
|
id: 'title:ascii',
|
||||||
text: '題名に英数字が混じってゐる?',
|
text: '題名に英数字が混じってゐる?',
|
||||||
kind: 'title' as const,
|
kind: 'title' as const,
|
||||||
|
condition: { type: 'title-has-ascii' as const },
|
||||||
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
||||||
.filter (question => {
|
.filter (question => {
|
||||||
const yes = posts.filter (post => question.test (post)).length
|
const yes = posts.filter (post => question.test (post)).length
|
||||||
@@ -294,6 +341,7 @@ 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,
|
||||||
|
question_condition: answer.questionCondition ?? null,
|
||||||
answer: answer.answer,
|
answer: answer.answer,
|
||||||
original_answer: answer.originalAnswer })) })
|
original_answer: answer.originalAnswer })) })
|
||||||
|
|
||||||
@@ -301,10 +349,13 @@ export const saveGekanatorGame = async ({
|
|||||||
export const saveGekanatorQuestionSuggestion = async ({
|
export const saveGekanatorQuestionSuggestion = async ({
|
||||||
gekanatorGameId,
|
gekanatorGameId,
|
||||||
questionText,
|
questionText,
|
||||||
|
answer,
|
||||||
}: {
|
}: {
|
||||||
gekanatorGameId: number
|
gekanatorGameId: number
|
||||||
questionText: string
|
questionText: string
|
||||||
|
answer: GekanatorAnswerValue
|
||||||
}): Promise<{ id: number }> =>
|
}): Promise<{ id: number }> =>
|
||||||
await apiPost ('/gekanator/question_suggestions', {
|
await apiPost ('/gekanator/question_suggestions', {
|
||||||
gekanator_game_id: gekanatorGameId,
|
gekanator_game_id: gekanatorGameId,
|
||||||
question_text: questionText })
|
question_text: questionText,
|
||||||
|
answer })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
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 { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
@@ -7,8 +7,10 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { buildGekanatorQuestions,
|
import { buildGekanatorQuestions,
|
||||||
fetchGekanatorPosts,
|
fetchGekanatorPosts,
|
||||||
|
restoreGekanatorQuestion,
|
||||||
saveGekanatorGame,
|
saveGekanatorGame,
|
||||||
saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
|
saveGekanatorQuestionSuggestion,
|
||||||
|
storeGekanatorQuestion } from '@/lib/gekanator'
|
||||||
import { gekanatorKeys } from '@/lib/queryKeys'
|
import { gekanatorKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -16,7 +18,8 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
import type { GekanatorAnswerLog,
|
import type { GekanatorAnswerLog,
|
||||||
GekanatorAnswerValue,
|
GekanatorAnswerValue,
|
||||||
GekanatorQuestion } from '@/lib/gekanator'
|
GekanatorQuestion,
|
||||||
|
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
@@ -61,6 +64,28 @@ type GameSnapshot = {
|
|||||||
reviewGuessedPostId: number | null
|
reviewGuessedPostId: number | null
|
||||||
reviewCorrectPostId: 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[] = [
|
const answerOptions: AnswerOption[] = [
|
||||||
{ label: 'はい', value: 'yes' },
|
{ label: 'はい', value: 'yes' },
|
||||||
{ label: 'いいえ', value: 'no' },
|
{ label: 'いいえ', value: 'no' },
|
||||||
@@ -78,6 +103,39 @@ const runnerUpMaxPercent = .5
|
|||||||
const hardMaxQuestions = 80
|
const hardMaxQuestions = 80
|
||||||
const softenedAnswerWeight = .35
|
const softenedAnswerWeight = .35
|
||||||
const confidenceTemperature = 6
|
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 => {
|
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||||
@@ -442,30 +500,124 @@ const expectedAnswerFor = (
|
|||||||
|
|
||||||
|
|
||||||
const GekanatorPage: FC = () => {
|
const GekanatorPage: FC = () => {
|
||||||
const [phase, setPhase] = useState<Phase> ('intro')
|
const storedGame = useMemo (loadStoredGame, [])
|
||||||
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||||||
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
|
const [scores, setScores] = useState<Map<number, number>> (
|
||||||
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
|
() => new Map (storedGame?.scores ?? []))
|
||||||
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
|
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> (
|
||||||
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> ([])
|
storedGame?.answers ?? [])
|
||||||
const [search, setSearch] = useState ('')
|
const [askedIds, setAskedIds] = useState<Set<string>> (
|
||||||
const [selectingCorrectPost, setSelectingCorrectPost] = useState (false)
|
() => new Set (storedGame?.askedIds ?? []))
|
||||||
const [saved, setSaved] = useState (false)
|
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
|
||||||
const [resultWon, setResultWon] = useState<boolean | null> (null)
|
() => new Set (storedGame?.softenedQuestionIds ?? []))
|
||||||
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
|
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
|
||||||
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
|
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
|
||||||
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
|
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
|
||||||
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
(storedGame?.askedQuestionBank?.length ?? 0) > 0
|
||||||
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
|
? []
|
||||||
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
|
: storedGame?.askedQuestionBankIds ?? [])
|
||||||
const [savedGameId, setSavedGameId] = useState<number | null> (null)
|
const [search, setSearch] = useState (storedGame?.search ?? '')
|
||||||
const [questionSuggestion, setQuestionSuggestion] = useState ('')
|
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 [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||||
|
|
||||||
const { data: posts = [], isLoading, error } = useQuery ({
|
const { data: posts = [], isLoading, error } = useQuery ({
|
||||||
queryKey: gekanatorKeys.posts (),
|
queryKey: gekanatorKeys.posts (),
|
||||||
queryFn: fetchGekanatorPosts })
|
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 (
|
const eligiblePosts = useMemo (
|
||||||
() => candidatePostsFor ({
|
() => candidatePostsFor ({
|
||||||
posts,
|
posts,
|
||||||
@@ -531,10 +683,12 @@ const GekanatorPage: FC = () => {
|
|||||||
mutationFn: saveGekanatorQuestionSuggestion,
|
mutationFn: saveGekanatorQuestionSuggestion,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setQuestionSuggestion ('')
|
setQuestionSuggestion ('')
|
||||||
|
setQuestionSuggestionAnswer ('yes')
|
||||||
reset ()
|
reset ()
|
||||||
}})
|
}})
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
|
clearStoredGame ()
|
||||||
saveMutation.reset ()
|
saveMutation.reset ()
|
||||||
setPhase ('intro')
|
setPhase ('intro')
|
||||||
setScores (new Map ())
|
setScores (new Map ())
|
||||||
@@ -554,6 +708,7 @@ const GekanatorPage: FC = () => {
|
|||||||
setReviewCorrectPostId (null)
|
setReviewCorrectPostId (null)
|
||||||
setSavedGameId (null)
|
setSavedGameId (null)
|
||||||
setQuestionSuggestion ('')
|
setQuestionSuggestion ('')
|
||||||
|
setQuestionSuggestionAnswer ('yes')
|
||||||
setHistory ([])
|
setHistory ([])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +814,7 @@ const GekanatorPage: FC = () => {
|
|||||||
const nextAnswers = [...answers, {
|
const nextAnswers = [...answers, {
|
||||||
questionId: currentQuestion.id,
|
questionId: currentQuestion.id,
|
||||||
questionText: currentQuestion.text,
|
questionText: currentQuestion.text,
|
||||||
|
questionCondition: currentQuestion.condition,
|
||||||
answer: value,
|
answer: value,
|
||||||
originalAnswer: value }]
|
originalAnswer: value }]
|
||||||
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||||||
@@ -784,7 +940,10 @@ const GekanatorPage: FC = () => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
saveReviewedResult (gekanatorGameId => {
|
saveReviewedResult (gekanatorGameId => {
|
||||||
questionSuggestionMutation.mutate ({ gekanatorGameId, questionText })
|
questionSuggestionMutation.mutate ({
|
||||||
|
gekanatorGameId,
|
||||||
|
questionText,
|
||||||
|
answer: questionSuggestionAnswer })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,7 +1095,12 @@ const GekanatorPage: FC = () => {
|
|||||||
{dialogue}
|
{dialogue}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading && <p>投稿を読み込んでゐます...</p>}
|
{isLoading && (
|
||||||
|
<p>
|
||||||
|
{phase === 'intro'
|
||||||
|
? '投稿を読み込んでゐます...'
|
||||||
|
: '前回のグカネータ状態を復元してゐます...'}
|
||||||
|
</p>)}
|
||||||
{Boolean (error) && <p>投稿を読み込めませんでした.</p>}
|
{Boolean (error) && <p>投稿を読み込めませんでした.</p>}
|
||||||
|
|
||||||
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
|
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
|
||||||
@@ -1008,7 +1172,7 @@ const GekanatorPage: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{phase === 'question' && !(currentQuestion) && (
|
{!(isLoading) && phase === 'question' && !(currentQuestion) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xl font-bold">
|
<p className="text-xl font-bold">
|
||||||
もう十分わかった。
|
もう十分わかった。
|
||||||
@@ -1157,7 +1321,8 @@ const GekanatorPage: FC = () => {
|
|||||||
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={saveMutation.isPending || questionSuggestionMutation.isPending}
|
disabled={saveMutation.isPending
|
||||||
|
|| questionSuggestionMutation.isPending}
|
||||||
onClick={() => setPhase ('question_suggestion')}>
|
onClick={() => setPhase ('question_suggestion')}>
|
||||||
質問を追加
|
質問を追加
|
||||||
</button>
|
</button>
|
||||||
@@ -1290,13 +1455,29 @@ const GekanatorPage: FC = () => {
|
|||||||
bg-white px-3 py-2 dark:border-red-700
|
bg-white px-3 py-2 dark:border-red-700
|
||||||
dark:bg-red-950"/>
|
dark:bg-red-950"/>
|
||||||
</label>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded border border-neutral-300 px-4 py-2
|
className="rounded border border-neutral-300 px-4 py-2
|
||||||
hover:bg-neutral-100 dark:border-neutral-700
|
hover:bg-neutral-100 dark:border-neutral-700
|
||||||
dark:hover:bg-red-900"
|
dark:hover:bg-red-900"
|
||||||
disabled={saveMutation.isPending || questionSuggestionMutation.isPending}
|
disabled={saveMutation.isPending
|
||||||
|
|| questionSuggestionMutation.isPending}
|
||||||
onClick={() => setPhase ('end')}>
|
onClick={() => setPhase ('end')}>
|
||||||
戻る
|
戻る
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする