From 49d42d576af960a90fc8b605c83d34bee3ac2333 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 9 Jun 2026 00:35:25 +0900 Subject: [PATCH] #41 --- backend/app/models/post.rb | 2 +- frontend/src/lib/gekanator.ts | 88 +++++++++++++++++++++++++- frontend/src/pages/GekanatorPage.tsx | 94 +++++++++++++++++++++++++--- 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index f682c52..15db1c5 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -19,7 +19,7 @@ class Post < ApplicationRecord has_many :gekanator_correct_games, class_name: 'GekanatorGame', foreign_key: :correct_post_id, - dependent: :nullify, + dependent: :delete_all, inverse_of: :correct_post has_many :parent_post_implications, diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index eae4623..f53b9a1 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -65,6 +65,37 @@ const originalYearOf = (post: Post): number | null => { } +const originalDateOf = (post: Post): Date | null => { + const value = post.originalCreatedFrom || post.originalCreatedBefore + if (!(value)) + return null + + const date = new Date (value) + if (Number.isNaN (date.getTime ())) + return null + + return date +} + + +const originalMonthOf = (post: Post): number | null => { + const date = originalDateOf (post) + if (!(date)) + return null + + return date.getMonth () + 1 +} + + +const originalMonthDayOf = (post: Post): string | null => { + const date = originalDateOf (post) + if (!(date)) + return null + + return `${ date.getMonth () + 1 }-${ date.getDate () }` +} + + const tagQuestionKey = ({ category, name }: { category: string; name: string }): string => `${ category }:${ name }` @@ -78,6 +109,27 @@ const tagFromQuestionKey = (key: string): { category: string; name: string } => const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '') +const tagQuestionText = (category: string, label: string): string => { + switch (category) + { + case 'deerjikist': + return `作者・ニジラーとして「${ label }」に関係してゐる?` + case 'meme': + return `元ネタ・ミームとして「${ label }」に関係しさう?` + case 'character': + return `「${ label }」といふキャラクターが関係してゐる?` + case 'material': + return `素材として「${ label }」に関係してゐる?` + case 'nico': + return `ニコニコに「${ label }」といふタグが付いてゐる?` + case 'general': + case 'meta': + default: + return `内容として「${ label }」に関係しさう?` + } +} + + const questionableTag = (post: Post, key: string): boolean => { const { category, name } = tagFromQuestionKey (key) @@ -128,6 +180,14 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { posts .map (originalYearOf) .filter ((year): year is number => year !== null)) + const originalMonths = countBy ( + posts + .map (originalMonthOf) + .filter ((month): month is number => month !== null)) + const originalMonthDays = countBy ( + posts + .map (originalMonthDayOf) + .filter ((monthDay): monthDay is string => monthDay !== null)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const usefulEntries = (counts: Map) => @@ -146,9 +206,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { return { id: `tag:${ key }`, - text: category === 'nico' - ? `ニコニコに「${ label }」といふタグが付いてゐる?` - : `内容として「${ label }」に関係しさう?`, + text: tagQuestionText (category, label), kind: 'tag' as const, test: (post: Post) => questionableTag (post, String (key)) } }) @@ -171,6 +229,28 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { kind: 'original_date' as const, test: (post: Post) => originalYearOf (post) === year })) + const originalMonthQuestions = usefulEntries (originalMonths) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 20) + .map (([month]) => ({ + id: `original-month:${ month }`, + text: `オリジナルの投稿月は ${ month } 月?`, + kind: 'original_date' as const, + test: (post: Post) => originalMonthOf (post) === month })) + + const originalMonthDayQuestions = usefulEntries (originalMonthDays) + .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) + .slice (0, 20) + .map (([monthDay]) => { + const [month, day] = String (monthDay).split ('-') + + return { + id: `original-month-day:${ monthDay }`, + text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`, + kind: 'original_date' as const, + test: (post: Post) => originalMonthDayOf (post) === monthDay } + }) + const titleQuestions = [ { id: 'title:long', @@ -191,6 +271,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { return [ ...sourceQuestions, ...originalYearQuestions, + ...originalMonthQuestions, + ...originalMonthDayQuestions, ...titleQuestions, ...tagQuestions] } diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index d6f2567..4d7f9d5 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -18,7 +18,14 @@ import type { GekanatorAnswerLog, GekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' -type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'review' | 'learned' +type Phase = + | 'intro' + | 'question' + | 'guess' + | 'continue' + | 'end' + | 'review' + | 'learned' type AnswerOption = { label: string @@ -687,9 +694,11 @@ const GekanatorPage: FC = () => { } } - const startReview = (correctPostId: number) => { + const finishGame = (correctPostId: number) => { const guessedPostId = - phase === 'continue' + phase === 'end' || phase === 'review' + ? reviewGuessedPostId + : phase === 'continue' ? lastRejectedGuessId ?? displayedGuess?.id : displayedGuess?.id ?? lastRejectedGuessId if (!(guessedPostId)) @@ -700,6 +709,16 @@ const GekanatorPage: FC = () => { setReviewCorrectPostId (correctPostId) setSearch ('') setSelectingCorrectPost (false) + setPhase ('end') + } + + const startReview = () => { + if (reviewGuessedPostId === null || reviewCorrectPostId === null) + return + + saveMutation.reset () + setSelectingCorrectPost (false) + setSearch ('') setPhase ('review') } @@ -805,7 +824,7 @@ const GekanatorPage: FC = () => { return } - startReview (post.id) + finishGame (post.id) } const filteredPosts = posts @@ -963,7 +982,7 @@ const GekanatorPage: FC = () => { hover:bg-pink-500" onClick={() => { if (displayedGuess) - startReview (displayedGuess.id) + finishGame (displayedGuess.id) }}> 当たり @@ -1022,6 +1041,67 @@ const GekanatorPage: FC = () => { )} + {phase === 'end' && ( +
+
+

ゲーム終了

+

今回の結果を保存できます。

+
+ + {reviewGuessedPost && ( +
+
推測した投稿
+ +
)} + +
+
正解の投稿
+ {reviewCorrectPost + ? + :

正解投稿を選んでください。

} + +
+ + {reviewGuessedPostId !== null && reviewCorrectPostId !== null && ( +

+ 判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'} +

)} + + {saveMutation.isError && ( +

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)} + +
+ + +
+
)} + {phase === 'review' && (
@@ -1105,7 +1185,7 @@ const GekanatorPage: FC = () => { className="rounded border border-neutral-300 px-4 py-2 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-red-900" - onClick={() => setPhase ('guess')}> + onClick={() => setPhase ('end')}> 戻る
@@ -1126,7 +1206,7 @@ const GekanatorPage: FC = () => {
- {['guess', 'continue', 'question', 'review'].includes (phase) + {['guess', 'continue', 'question', 'end', 'review'].includes (phase) && selectingCorrectPost && (