diff --git a/prisma/migrations/20240925092027_refine_problem_session/migration.sql b/prisma/migrations/20240925092027_refine_problem_session/migration.sql new file mode 100644 index 00000000..dcdc9bc4 --- /dev/null +++ b/prisma/migrations/20240925092027_refine_problem_session/migration.sql @@ -0,0 +1,22 @@ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "ProblemSession"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "ProblemSession" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "lectureId" TEXT NOT NULL, + "problemId" TEXT NOT NULL, + "problemVariablesSeed" TEXT NOT NULL, + "problemType" TEXT NOT NULL, + "traceItemIndex" INTEGER NOT NULL, + "elapsedMilliseconds" INTEGER NOT NULL DEFAULT 0, + "completedAt" DATETIME, + CONSTRAINT "ProblemSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE INDEX "ProblemSession_userId_courseId_lectureId_problemId_completedAt_idx" ON "ProblemSession"("userId", "courseId", "lectureId", "problemId", "completedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82615d2d..a87a62b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,10 +33,9 @@ model ProblemSession { lectureId String problemId String - problemVariablesSeed String - currentProblemType String - currentTraceItemIndex Int - previousTraceItemIndex Int + problemVariablesSeed String + problemType String + traceItemIndex Int elapsedMilliseconds Int @default(0) completedAt DateTime? diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/Problems.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/Problems.tsx deleted file mode 100644 index 2789625d..00000000 --- a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/Problems.tsx +++ /dev/null @@ -1,310 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { MdOutlineInfo } from 'react-icons/md'; - -import { CustomModal } from '../../../../../../../../components/molecules/CustomModal'; -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Box, - Button, - Card, - Flex, - Heading, - HStack, - Icon, - Tooltip, - useDisclosure, - VStack, -} from '../../../../../../../../infrastructures/useClient/chakra'; -import type { Problem } from '../../../../../../../../problems/generateProblem'; -import type { ProblemType } from '../../../../../../../../types'; -import { isMacOS } from '../../../../../../../../utils/platform'; - -import type { TurtleGraphicsHandle } from './BoardEditor'; -import { BoardEditor } from './BoardEditor'; -import { BoardViewer } from './BoardViewer'; -import { SyntaxHighlighter } from './SyntaxHighlighter'; -import { Variables } from './Variables'; - -interface ExecutionResultProblemProps { - problem: Problem; - createSubmission: (isCorrect: boolean) => void; - handleComplete: () => void; - setCurrentTraceItemIndex: (line: number) => void; - setProblemType: (step: ProblemType) => void; -} - -export const ExecutionResultProblem: React.FC = (props) => { - console.log('ExecutionResultProblem'); - return ( - - ); -}; - -interface StepProblemProps { - previousTraceItemIndex: number; - createSubmission: (isCorrect: boolean) => void; - currentTraceItemIndex: number; - problem: Problem; - handleComplete: () => void; - setPreviousTraceItemIndex: (line: number) => void; - setCurrentTraceItemIndex: (line: number) => void; -} - -export const StepProblem: React.FC = (props) => { - console.log('StepProblem'); - return ; -}; - -interface ProblemProps { - problem: Problem; - createSubmission: (isCorrect: boolean) => void; - setCurrentTraceItemIndex: (line: number) => void; - setProblemType?: (step: ProblemType) => void; - handleComplete?: () => void; - previousTraceItemIndex: number; - currentTraceItemIndex: number; - setPreviousTraceItemIndex?: (line: number) => void; -} - -const ProblemComponent: React.FC = ({ - createSubmission, - currentTraceItemIndex, - handleComplete, - previousTraceItemIndex, - problem, - setCurrentTraceItemIndex, - setPreviousTraceItemIndex, - setProblemType, - type, -}) => { - const turtleGraphicsRef = useRef(null); - const { isOpen: isHelpModalOpen, onClose: onHelpModalClose, onOpen: onHelpModalOpen } = useDisclosure(); - const { isOpen: isAlertOpen, onClose: onAlertClose, onOpen: onAlertOpen } = useDisclosure(); - const cancelRef = useRef(null); - - const [alertTitle, setAlertTitle] = useState(''); - const [alertMessage, setAlertMessage] = useState(''); - const [postAlertAction, setPostAlertAction] = useState<() => void>(); - - const handleClickResetButton = (): void => { - turtleGraphicsRef.current?.initialize(); - }; - - const openAlertDialog = (title: string, message: string, action?: () => void): void => { - setAlertTitle(title); - setAlertMessage(message); - setPostAlertAction(() => action); - onAlertOpen(); - }; - - const handleClickAnswerButton = async (): Promise => { - const isCorrect = turtleGraphicsRef.current?.isCorrect() || false; - createSubmission(isCorrect); - - switch (type) { - case 'executionResult': { - if (!isCorrect) { - openAlertDialog('不正解', '不正解です。ステップごとに回答してください', () => { - setCurrentTraceItemIndex(1); - setProblemType?.('step'); - }); - break; - } - - handleComplete?.(); - openAlertDialog('正解', '正解です。この問題は終了です'); - break; - } - case 'step': { - if (!isCorrect) { - openAlertDialog('不正解', '不正解です。もう一度回答してください'); - break; - } - - if (currentTraceItemIndex === problem.traceItems.length - 1) { - handleComplete?.(); - openAlertDialog('正解', '正解です。この問題は終了です'); - } else { - openAlertDialog('正解', '正解です。次の行に進みます', () => { - setPreviousTraceItemIndex?.(currentTraceItemIndex); - setCurrentTraceItemIndex(currentTraceItemIndex + 1); - }); - } - break; - } - } - }; - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent): void => { - if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { - event.preventDefault(); - void handleClickAnswerButton(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - <> - - - - - 問題 - -
- - {type === 'executionResult' ? ( - 'プログラムを実行した後' - ) : ( - <> - - {problem.sidToLineIndex.get(problem.traceItems[currentTraceItemIndex].sid)}行目 - - を実行した後 - - )} - - の盤面を作成してください。 -
- - - - - -
- - -
- - {type !== 'executionResult' && - problem.sidToLineIndex.get(problem.traceItems[previousTraceItemIndex]?.sid) && ( - - - - 参考: - - {problem.sidToLineIndex.get(problem.traceItems[previousTraceItemIndex]?.sid)}行目 - - を実行した後( - - {problem.sidToLineIndex.get(problem.traceItems[currentTraceItemIndex]?.sid)}行目 - - を実行する前)の盤面 - - - - - - - - )} -
- - - - - - - - - - - - { - postAlertAction?.(); - onAlertClose(); - }} - > - - - - {alertTitle} - - {alertMessage} - - - - - - - - -
- - ); -}; diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/ProblmBody.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/ProblmBody.tsx new file mode 100644 index 00000000..5362334c --- /dev/null +++ b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/ProblmBody.tsx @@ -0,0 +1,271 @@ +import type { ProblemSession } from '@prisma/client'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { MdOutlineInfo } from 'react-icons/md'; + +import { CustomModal } from '../../../../../../../../components/molecules/CustomModal'; +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + Card, + Heading, + HStack, + Icon, + Tooltip, + useDisclosure, + VStack, +} from '../../../../../../../../infrastructures/useClient/chakra'; +import type { Problem } from '../../../../../../../../problems/generateProblem'; +import type { CourseId, ProblemId } from '../../../../../../../../problems/problemData'; +import { isMacOS } from '../../../../../../../../utils/platform'; + +import type { TurtleGraphicsHandle } from './BoardEditor'; +import { BoardEditor } from './BoardEditor'; +import { BoardViewer } from './BoardViewer'; +import { SyntaxHighlighter } from './SyntaxHighlighter'; +import { Variables } from './Variables'; + +type Props = { + params: { courseId: CourseId; lectureId: string; problemId: ProblemId }; + problem: Problem; + problemSession: ProblemSession; + createSubmissionUpdatingProblemSession: (isCorrect: boolean, isCompleted: boolean) => Promise; + updateProblemSession: (problemType: string, traceItemIndex: number) => Promise; +}; + +export const ProblemBody: React.FC = (props) => { + const problemType = props.problemSession.problemType; + const currentTraceItemIndex = + problemType === 'executionResult' ? props.problem.traceItems.length - 1 : props.problemSession.traceItemIndex; + const previousTraceItemIndex = problemType === 'executionResult' ? 0 : currentTraceItemIndex - 1; + + const { isOpen: isHelpModalOpen, onClose: onHelpModalClose, onOpen: onHelpModalOpen } = useDisclosure(); + const { isOpen: isAlertOpen, onClose: onAlertClose, onOpen: onAlertOpen } = useDisclosure(); + const cancelRef = useRef(null); + + const turtleGraphicsRef = useRef(null); + + const router = useRouter(); + + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [postAlertAction, setPostAlertAction] = useState<() => void>(); + + const openAlertDialog = (title: string, message: string, action?: () => void): void => { + setAlertTitle(title); + setAlertMessage(message); + setPostAlertAction(() => action); + onAlertOpen(); + }; + + const handleClickAnswerButton = async (): Promise => { + const isCorrect = turtleGraphicsRef.current?.isCorrect() || false; + + switch (problemType) { + case 'executionResult': { + void props.createSubmissionUpdatingProblemSession(isCorrect, isCorrect); + if (isCorrect) { + openAlertDialog( + '正解', + '一発正解です!この問題は完了です。問題一覧ページに戻って、次の問題に挑戦してください。', + () => { + router.push(`/courses/${props.params.courseId}/lectures/${props.params.lectureId}`); + } + ); + } else { + void props.updateProblemSession('step', 1); + openAlertDialog('不正解', '不正解です。ステップごとに問題を解いてみましょう。'); + } + break; + } + case 'step': { + void props.createSubmissionUpdatingProblemSession( + isCorrect, + isCorrect && currentTraceItemIndex === props.problem.traceItems.length - 1 + ); + if (isCorrect) { + if (currentTraceItemIndex === props.problem.traceItems.length - 1) { + openAlertDialog( + '正解', + '正解です。この問題は完了です。問題一覧ページに戻って、次の問題に挑戦してください。', + () => { + router.push(`/courses/${props.params.courseId}/lectures/${props.params.lectureId}`); + } + ); + } else { + void props.updateProblemSession('step', currentTraceItemIndex + 1); + openAlertDialog('正解', '正解です。次のステップに進みます。'); + } + } else { + openAlertDialog('不正解', '不正解です。もう一度解答してください。'); + } + break; + } + } + }; + + useShortcutKeys(handleClickAnswerButton); + + return ( + <> + + + + 問題 + +
+ + {problemType === 'executionResult' ? ( + 'プログラムを実行した後' + ) : ( + <> + + {props.problem.sidToLineIndex.get(props.problem.traceItems[currentTraceItemIndex].sid)}行目 + + を実行した後 + + )} + + の盤面を作成してください。 +
+ + + + + +
+ + +
+ + {problemType !== 'executionResult' && + props.problem.sidToLineIndex.get(props.problem.traceItems[previousTraceItemIndex]?.sid) && ( + + + + 参考: + + {props.problem.sidToLineIndex.get(props.problem.traceItems[previousTraceItemIndex]?.sid)}行目 + + を実行した後( + + {props.problem.sidToLineIndex.get(props.problem.traceItems[currentTraceItemIndex]?.sid)}行目 + + を実行する前)の盤面 + + + + + + + + )} +
+ + + + + + + + + + + + { + postAlertAction?.(); + onAlertClose(); + }} + > + + + + {alertTitle} + + {alertMessage} + + + + + + + + + + ); +}; + +function useShortcutKeys(handleClickAnswerButton: () => Promise): void { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + void handleClickAnswerButton(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx index 9879e132..9e644a7b 100644 --- a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx +++ b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx @@ -34,9 +34,8 @@ const ProblemPage: NextPage = async (props) => { lectureId: props.params.lectureId, problemId: props.params.problemId, problemVariablesSeed: Date.now().toString(), - currentProblemType: 'executionResult', - currentTraceItemIndex: 0, - previousTraceItemIndex: 0, + problemType: 'executionResult', + traceItemIndex: 0, }, }); } diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx index e5834a78..06b6e027 100644 --- a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx +++ b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx @@ -2,7 +2,7 @@ import type { ProblemSession } from '@prisma/client'; import NextLink from 'next/link'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useIdleTimer } from 'react-idle-timer'; import { @@ -10,13 +10,12 @@ import { MIN_INTERVAL_MS_OF_ACTIVE_EVENTS, } from '../../../../../../../../constants'; import { backendTrpcReact } from '../../../../../../../../infrastructures/trpcBackend/client'; -import { Heading, HStack, Link, Text, VStack } from '../../../../../../../../infrastructures/useClient/chakra'; +import { Flex, Heading, HStack, Link, Text, VStack } from '../../../../../../../../infrastructures/useClient/chakra'; import type { Problem } from '../../../../../../../../problems/generateProblem'; import type { CourseId, ProblemId } from '../../../../../../../../problems/problemData'; import { courseIdToLectureIds, courseIdToName, problemIdToName } from '../../../../../../../../problems/problemData'; -import type { ProblemType } from '../../../../../../../../types'; -import { ExecutionResultProblem, StepProblem } from './Problems'; +import { ProblemBody } from './ProblmBody'; type Props = { params: { courseId: CourseId; lectureId: string; problemId: ProblemId }; @@ -28,61 +27,36 @@ type Props = { export const ProblemPageOnClient: React.FC = (props) => { const lectureIndex = courseIdToLectureIds[props.params.courseId].indexOf(props.params.lectureId); - const [problemType, setProblemType] = useState(props.initialProblemSession.currentProblemType as ProblemType); - const [currentTraceItemIndex, setCurrentTraceItemIndex] = useState(props.initialProblemSession.currentTraceItemIndex); - const [previousTraceItemIndex, setPreviousTraceItemIndex] = useState( - props.initialProblemSession.previousTraceItemIndex - ); - - const updateProblemSessionMutation = backendTrpcReact.updateProblemSession.useMutation(); - const createProblemSubmissionMutation = backendTrpcReact.createProblemSubmission.useMutation(); + const [problemSession, setProblemSession] = useState(props.initialProblemSession); const lastActionTimeRef = useRef(Date.now()); - useIdleTimer({ - async onAction() { - await updateProblemSessionMutation.mutateAsync({ - id: props.initialProblemSession.id, - incrementalElapsedMilliseconds: getIncrementalElapsedMilliseconds(lastActionTimeRef), - }); - }, - // Events within the throttle period are ignored. - throttle: MIN_INTERVAL_MS_OF_ACTIVE_EVENTS, - }); - - useEffect(() => { - // ステップ実行中に以下の式は成り立たない。不整合を起こさないように、念の為チェックする。 - if (currentTraceItemIndex === previousTraceItemIndex) return; + useMonitorUserActivity(props, lastActionTimeRef); - void updateProblemSessionMutation.mutateAsync({ - id: props.initialProblemSession.id, - incrementalElapsedMilliseconds: getIncrementalElapsedMilliseconds(lastActionTimeRef), - currentProblemType: problemType, - currentTraceItemIndex, - previousTraceItemIndex, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentTraceItemIndex]); + const updateProblemSessionMutation = backendTrpcReact.updateProblemSession.useMutation(); + const createProblemSubmissionMutation = backendTrpcReact.createProblemSubmission.useMutation(); - const createSubmission = async (isCorrect: boolean): Promise => { - const { elapsedMilliseconds } = await updateProblemSessionMutation.mutateAsync({ - id: props.initialProblemSession.id, + const createSubmissionUpdatingProblemSession = async (isCorrect: boolean, isCompleted: boolean): Promise => { + const newProblemSession = await updateProblemSessionMutation.mutateAsync({ + id: problemSession.id, incrementalElapsedMilliseconds: getIncrementalElapsedMilliseconds(lastActionTimeRef), + completedAt: isCompleted ? new Date() : undefined, }); await createProblemSubmissionMutation.mutateAsync({ - sessionId: props.initialProblemSession.id, - problemType, - traceItemIndex: currentTraceItemIndex, - elapsedMilliseconds, + sessionId: problemSession.id, + problemType: problemSession.problemType, + traceItemIndex: problemSession.traceItemIndex, + elapsedMilliseconds: newProblemSession.elapsedMilliseconds, isCorrect, }); }; - const handleSolveProblem = async (): Promise => { - await updateProblemSessionMutation.mutateAsync({ - id: props.initialProblemSession.id, - incrementalElapsedMilliseconds: getIncrementalElapsedMilliseconds(lastActionTimeRef), - completedAt: new Date(), + const updateProblemSession = async (problemType: string, traceItemIndex: number): Promise => { + const newProblemSession = await updateProblemSessionMutation.mutateAsync({ + id: problemSession.id, + problemType, + traceItemIndex, }); + setProblemSession(newProblemSession); }; return ( @@ -105,29 +79,34 @@ export const ProblemPageOnClient: React.FC = (props) => { {problemIdToName[props.params.problemId]} - {problemType === 'executionResult' ? ( - - ) : ( - + - )} + ); }; +function useMonitorUserActivity(props: Props, lastActionTimeRef: React.MutableRefObject): void { + const updateProblemSessionMutation = backendTrpcReact.updateProblemSession.useMutation(); + + useIdleTimer({ + async onAction() { + await updateProblemSessionMutation.mutateAsync({ + id: props.initialProblemSession.id, + incrementalElapsedMilliseconds: getIncrementalElapsedMilliseconds(lastActionTimeRef), + }); + }, + // Events within the throttle period are ignored. + throttle: MIN_INTERVAL_MS_OF_ACTIVE_EVENTS, + }); +} + function getIncrementalElapsedMilliseconds(lastActionTimeRef: React.MutableRefObject): number { const nowTime = Date.now(); const incrementalElapsedMilliseconds = Math.min( diff --git a/src/infrastructures/trpcBackend/routers/index.ts b/src/infrastructures/trpcBackend/routers/index.ts index 6f95547e..5765b7c5 100644 --- a/src/infrastructures/trpcBackend/routers/index.ts +++ b/src/infrastructures/trpcBackend/routers/index.ts @@ -17,9 +17,8 @@ export const backendRouter = router({ .input( z.object({ id: z.number().int().positive(), - currentProblemType: z.string().min(1).optional(), - currentTraceItemIndex: z.number().int().nonnegative().optional(), - previousTraceItemIndex: z.number().int().nonnegative().optional(), + problemType: z.string().min(1).optional(), + traceItemIndex: z.number().int().nonnegative().optional(), incrementalElapsedMilliseconds: z.number().nonnegative().optional(), completedAt: z.date().optional(), }) @@ -28,7 +27,9 @@ export const backendRouter = router({ const problemSession = await prisma.problemSession.update({ where: { id }, data: { - elapsedMilliseconds: { increment: incrementalElapsedMilliseconds }, + ...(incrementalElapsedMilliseconds + ? { elapsedMilliseconds: { increment: incrementalElapsedMilliseconds } } + : {}), ...data, }, });