feat(study): mock exam flow from KICE problem sets — 7F.2
This commit is contained in:
@@ -11,7 +11,10 @@ export class ProblemSetsService {
|
||||
...(opts.subjectName && { subjectName: opts.subjectName }),
|
||||
...(opts.year && { year: opts.year }),
|
||||
},
|
||||
include: { _count: { select: { problems: true } } },
|
||||
include: {
|
||||
_count: { select: { problems: true } },
|
||||
problems: { select: { needsReview: true } },
|
||||
},
|
||||
orderBy: [{ year: 'desc' }, { subjectName: 'asc' }],
|
||||
});
|
||||
}
|
||||
@@ -21,6 +24,7 @@ export class ProblemSetsService {
|
||||
where: { id },
|
||||
include: {
|
||||
problems: { orderBy: { number: 'asc' } },
|
||||
passages: { orderBy: { startNumber: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!ps) throw new NotFoundException();
|
||||
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
@@ -88,6 +92,42 @@ class ListStudyLogQuery {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
class ProblemSetAnswerDto {
|
||||
@IsInt()
|
||||
problemId: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
chosenAnswer?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
class CreateFromProblemSetDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
problemSetId: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalTimeSpent: number;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(200)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProblemSetAnswerDto)
|
||||
answers: ProblemSetAnswerDto[];
|
||||
}
|
||||
|
||||
@Controller('study-logs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StudyLogsController {
|
||||
@@ -98,6 +138,14 @@ export class StudyLogsController {
|
||||
return this.svc.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('from-problem-set')
|
||||
createFromProblemSet(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: CreateFromProblemSetDto,
|
||||
) {
|
||||
return this.svc.createFromProblemSet(user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser, @Query() q: ListStudyLogQuery) {
|
||||
return this.svc.list(user.id, q);
|
||||
|
||||
@@ -2,8 +2,9 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { StudyResult } from '@prisma/client';
|
||||
import { Prisma, Persona, ReviewIntensity, StudyResult } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
|
||||
@@ -19,6 +20,16 @@ export interface CreateStudyLogInput {
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
export interface CreateFromProblemSetInput {
|
||||
problemSetId: number;
|
||||
totalTimeSpent: number;
|
||||
answers: Array<{
|
||||
problemId: number;
|
||||
chosenAnswer?: number | null;
|
||||
timeSpent?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating a study log has two side-effects:
|
||||
* 1. Update (or create) the SkillSnapshot for (user, tag).
|
||||
@@ -36,106 +47,151 @@ export class StudyLogsService {
|
||||
) {}
|
||||
|
||||
async create(userId: number, input: CreateStudyLogInput) {
|
||||
// ── ownership checks ──
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id: input.subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new ForbiddenException('subject');
|
||||
await this.validateCreateInput(this.prisma, userId, input);
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
if (input.tagId) {
|
||||
const tag = await this.prisma.tag.findFirst({
|
||||
where: { id: input.tagId, subjectId: input.subjectId },
|
||||
});
|
||||
if (!tag) throw new NotFoundException('tag');
|
||||
}
|
||||
|
||||
if (input.problemId) {
|
||||
const problem = await this.prisma.problem.findUnique({
|
||||
where: { id: input.problemId },
|
||||
});
|
||||
if (!problem) throw new NotFoundException('problem');
|
||||
}
|
||||
|
||||
// ── persona / intensity ──
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Difficulty: user provides 0..1; if baseCorrectRate given, blend.
|
||||
const D =
|
||||
input.baseCorrectRate !== undefined && input.baseCorrectRate !== null
|
||||
? 1 - input.baseCorrectRate
|
||||
: clamp01(input.difficulty);
|
||||
|
||||
// ── transaction ──
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 1. create the study log
|
||||
const log = await tx.studyLog.create({
|
||||
data: {
|
||||
userId,
|
||||
subjectId: input.subjectId,
|
||||
tagId: input.tagId ?? null,
|
||||
problemId: input.problemId ?? null,
|
||||
title: input.title,
|
||||
difficulty: clamp01(input.difficulty),
|
||||
baseCorrectRate: input.baseCorrectRate ?? null,
|
||||
result: input.result,
|
||||
memo: input.memo ?? null,
|
||||
timeSpent: input.timeSpent ?? null,
|
||||
return this.createInTransaction(tx, userId, user, input);
|
||||
});
|
||||
}
|
||||
|
||||
async createFromProblemSet(userId: number, input: CreateFromProblemSetInput) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const problemSet = await tx.problemSet.findUnique({
|
||||
where: { id: input.problemSetId },
|
||||
include: {
|
||||
problems: { orderBy: { number: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!problemSet) throw new NotFoundException('problemSet');
|
||||
|
||||
// 2. upsert skill snapshot (if tagId present)
|
||||
let s0 = 0.3;
|
||||
if (input.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
});
|
||||
const prevS0 = existing?.s0 ?? null;
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: prevS0,
|
||||
result: input.result,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
create: {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const subject = await tx.subject.upsert({
|
||||
where: {
|
||||
userId_name: {
|
||||
userId,
|
||||
tagId: input.tagId,
|
||||
s0,
|
||||
lastUpdatedAt: new Date(),
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0,
|
||||
lastUpdatedAt: new Date(),
|
||||
sampleCount: { increment: 1 },
|
||||
name: problemSet.subjectName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
name: problemSet.subjectName,
|
||||
color: defaultSubjectColor(problemSet.subjectName),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const problemById = new Map(problemSet.problems.map((problem) => [problem.id, problem]));
|
||||
const seenProblemIds = new Set<number>();
|
||||
let correct = 0;
|
||||
let incorrect = 0;
|
||||
let skipped = 0;
|
||||
const studyLogIds: number[] = [];
|
||||
const results: Array<{
|
||||
problemId: number;
|
||||
number: number;
|
||||
title: string;
|
||||
bodyText: string | null;
|
||||
chosenAnswer: number | null;
|
||||
correctAnswer: number | null;
|
||||
result: StudyResult | 'skipped';
|
||||
studyLogId: number | null;
|
||||
}> = [];
|
||||
|
||||
for (const answer of input.answers) {
|
||||
if (seenProblemIds.has(answer.problemId)) {
|
||||
throw new BadRequestException(`duplicate problemId: ${answer.problemId}`);
|
||||
}
|
||||
seenProblemIds.add(answer.problemId);
|
||||
|
||||
const problem = problemById.get(answer.problemId);
|
||||
if (!problem) {
|
||||
throw new BadRequestException(
|
||||
`problemId ${answer.problemId} does not belong to this problem set`,
|
||||
);
|
||||
}
|
||||
|
||||
const chosenAnswer = answer.chosenAnswer ?? null;
|
||||
if (chosenAnswer === null) {
|
||||
skipped += 1;
|
||||
results.push({
|
||||
problemId: problem.id,
|
||||
number: problem.number,
|
||||
title: problem.title,
|
||||
bodyText: problem.bodyText,
|
||||
chosenAnswer: null,
|
||||
correctAnswer: problem.answerNumber,
|
||||
result: 'skipped',
|
||||
studyLogId: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const derivedResult =
|
||||
problem.answerNumber === null
|
||||
? 'partial'
|
||||
: chosenAnswer === problem.answerNumber
|
||||
? 'correct'
|
||||
: 'incorrect';
|
||||
|
||||
if (derivedResult === 'correct') correct += 1;
|
||||
if (derivedResult === 'incorrect') incorrect += 1;
|
||||
|
||||
const created = await this.createInTransaction(tx, userId, user, {
|
||||
subjectId: subject.id,
|
||||
problemId: problem.id,
|
||||
title: `${problemSet.title} ${problem.number}번`,
|
||||
difficulty: problem.difficulty,
|
||||
baseCorrectRate: problem.baseCorrectRate,
|
||||
result: derivedResult,
|
||||
memo:
|
||||
problem.answerNumber === null
|
||||
? '정답 미등록 (자동 채점 불가)'
|
||||
: undefined,
|
||||
timeSpent: answer.timeSpent,
|
||||
});
|
||||
|
||||
studyLogIds.push(created.studyLog.id);
|
||||
results.push({
|
||||
problemId: problem.id,
|
||||
number: problem.number,
|
||||
title: problem.title,
|
||||
bodyText: problem.bodyText,
|
||||
chosenAnswer,
|
||||
correctAnswer: problem.answerNumber,
|
||||
result: derivedResult,
|
||||
studyLogId: created.studyLog.id,
|
||||
});
|
||||
} else {
|
||||
s0 = this.forget.updateS0({ previousS0: null, result: input.result });
|
||||
}
|
||||
|
||||
// 3. schedule next review
|
||||
const schedule = this.forget.schedule({
|
||||
s0,
|
||||
persona: user.persona,
|
||||
intensity: user.reviewIntensity,
|
||||
difficulty: D,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
for (const problem of problemSet.problems) {
|
||||
if (seenProblemIds.has(problem.id)) continue;
|
||||
skipped += 1;
|
||||
results.push({
|
||||
problemId: problem.id,
|
||||
number: problem.number,
|
||||
title: problem.title,
|
||||
bodyText: problem.bodyText,
|
||||
chosenAnswer: null,
|
||||
correctAnswer: problem.answerNumber,
|
||||
result: 'skipped',
|
||||
studyLogId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const reviewRow = await tx.reviewSchedule.create({
|
||||
data: {
|
||||
userId,
|
||||
studyLogId: log.id,
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
predictedP: schedule.predictedP,
|
||||
iteration: 0,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
const total = problemSet.problems.length;
|
||||
const accuracy = total === 0 ? 0 : Math.round((correct / total) * 100);
|
||||
|
||||
return { studyLog: log, nextReview: reviewRow, s0 };
|
||||
return {
|
||||
total,
|
||||
correct,
|
||||
incorrect,
|
||||
skipped,
|
||||
accuracy,
|
||||
totalTimeSpent: input.totalTimeSpent,
|
||||
studyLogIds,
|
||||
results,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,8 +231,123 @@ export class StudyLogsService {
|
||||
if (!log) throw new NotFoundException();
|
||||
return log;
|
||||
}
|
||||
|
||||
private async validateCreateInput(
|
||||
db: Prisma.TransactionClient | PrismaService,
|
||||
userId: number,
|
||||
input: CreateStudyLogInput,
|
||||
) {
|
||||
const subject = await db.subject.findFirst({
|
||||
where: { id: input.subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new ForbiddenException('subject');
|
||||
|
||||
if (input.tagId) {
|
||||
const tag = await db.tag.findFirst({
|
||||
where: { id: input.tagId, subjectId: input.subjectId },
|
||||
});
|
||||
if (!tag) throw new NotFoundException('tag');
|
||||
}
|
||||
|
||||
if (input.problemId) {
|
||||
const problem = await db.problem.findUnique({
|
||||
where: { id: input.problemId },
|
||||
});
|
||||
if (!problem) throw new NotFoundException('problem');
|
||||
}
|
||||
}
|
||||
|
||||
private async createInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: number,
|
||||
user: { persona: Persona; reviewIntensity: ReviewIntensity },
|
||||
input: CreateStudyLogInput,
|
||||
) {
|
||||
const now = new Date();
|
||||
const D =
|
||||
input.baseCorrectRate !== undefined && input.baseCorrectRate !== null
|
||||
? 1 - input.baseCorrectRate
|
||||
: clamp01(input.difficulty);
|
||||
|
||||
const log = await tx.studyLog.create({
|
||||
data: {
|
||||
userId,
|
||||
subjectId: input.subjectId,
|
||||
tagId: input.tagId ?? null,
|
||||
problemId: input.problemId ?? null,
|
||||
title: input.title,
|
||||
difficulty: clamp01(input.difficulty),
|
||||
baseCorrectRate: input.baseCorrectRate ?? null,
|
||||
result: input.result,
|
||||
memo: input.memo ?? null,
|
||||
timeSpent: input.timeSpent ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
let s0 = 0.3;
|
||||
if (input.tagId) {
|
||||
const existing = await tx.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
});
|
||||
const prevS0 = existing?.s0 ?? null;
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: prevS0,
|
||||
result: input.result,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
create: {
|
||||
userId,
|
||||
tagId: input.tagId,
|
||||
s0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: 1,
|
||||
},
|
||||
update: {
|
||||
s0,
|
||||
lastUpdatedAt: now,
|
||||
sampleCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
s0 = this.forget.updateS0({ previousS0: null, result: input.result });
|
||||
}
|
||||
|
||||
const schedule = this.forget.schedule({
|
||||
s0,
|
||||
persona: user.persona,
|
||||
intensity: user.reviewIntensity,
|
||||
difficulty: D,
|
||||
lastUpdatedAt: now,
|
||||
});
|
||||
|
||||
const reviewRow = await tx.reviewSchedule.create({
|
||||
data: {
|
||||
userId,
|
||||
studyLogId: log.id,
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
predictedP: schedule.predictedP,
|
||||
iteration: 0,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
return { studyLog: log, nextReview: reviewRow, s0 };
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function defaultSubjectColor(subjectName: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
국어: '#ef4444',
|
||||
수학: '#3b82f6',
|
||||
영어: '#22c55e',
|
||||
한국사: '#f59e0b',
|
||||
'생활과 윤리': '#f59e0b',
|
||||
};
|
||||
|
||||
return colorMap[subjectName] ?? '#6366f1';
|
||||
}
|
||||
|
||||
888
frontend/src/app/study/exam/[problemSetId]/page.tsx
Normal file
888
frontend/src/app/study/exam/[problemSetId]/page.tsx
Normal file
@@ -0,0 +1,888 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { ConfirmDialog } from '@/components/ui/Modal';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { getProblemSet, submitProblemSetStudy, type ProblemSetDetail } from '@/lib/api';
|
||||
import { hasToken } from '@/lib/auth';
|
||||
import { theme } from '@/styles/theme';
|
||||
import {
|
||||
formatClock,
|
||||
getExamDurationSeconds,
|
||||
storeExamResult,
|
||||
} from '@/components/exam/shared';
|
||||
|
||||
type AnswerMap = Record<number, number | null>;
|
||||
|
||||
export default function ExamPage() {
|
||||
const params = useParams<{ problemSetId: string }>();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const problemSetId = Number(params.problemSetId);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [problemSet, setProblemSet] = useState<ProblemSetDetail | null>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<AnswerMap>({});
|
||||
const [flagged, setFlagged] = useState<Record<number, boolean>>({});
|
||||
const [visited, setVisited] = useState<Record<number, boolean>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
|
||||
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const examStartedAtRef = useRef<number>(0);
|
||||
const problemEnteredAtRef = useRef<number>(0);
|
||||
const problemTimeMsRef = useRef<Record<number, number>>({});
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
const problems = problemSet?.problems ?? [];
|
||||
const currentProblem = problems[currentIndex] ?? null;
|
||||
const passage = useMemo(() => {
|
||||
if (!problemSet || !currentProblem?.passageId) return null;
|
||||
return (
|
||||
problemSet.passages.find((item) => item.id === currentProblem.passageId) ?? null
|
||||
);
|
||||
}, [problemSet, currentProblem]);
|
||||
const answeredCount = useMemo(
|
||||
() => problems.filter((problem) => answers[problem.id] !== undefined && answers[problem.id] !== null).length,
|
||||
[answers, problems],
|
||||
);
|
||||
|
||||
const loadProblemSet = async () => {
|
||||
if (!Number.isFinite(problemSetId)) {
|
||||
setLoading(false);
|
||||
setError('잘못된 문제집 주소야.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProblemSet(problemSetId);
|
||||
setProblemSet(data);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setFlagged({});
|
||||
setVisited(data.problems[0] ? { [data.problems[0].id]: true } : {});
|
||||
setRemainingSeconds(getExamDurationSeconds(data.subjectName));
|
||||
examStartedAtRef.current = Date.now();
|
||||
problemEnteredAtRef.current = Date.now();
|
||||
problemTimeMsRef.current = {};
|
||||
submittedRef.current = false;
|
||||
} catch {
|
||||
setError('문제집을 불러오지 못했어. 네트워크 상태를 확인해줘.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasToken()) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
void loadProblemSet();
|
||||
}, [problemSetId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!problemSet || submitting || submittedRef.current) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setRemainingSeconds((prev) => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [problemSet, submitting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!problemSet || submitting || submittedRef.current) return;
|
||||
if (remainingSeconds !== 0) return;
|
||||
void finishExam(true);
|
||||
}, [problemSet, remainingSeconds, submitting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!problemSet || !currentProblem) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
||||
|
||||
if (showLeaveConfirm || showSubmitConfirm) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setShowLeaveConfirm(false);
|
||||
setShowSubmitConfirm(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key >= '1' && event.key <= '5') {
|
||||
event.preventDefault();
|
||||
selectAnswer(Number(event.key));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
moveProblem(currentIndex - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
moveProblem(currentIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault();
|
||||
toggleFlag(currentProblem.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && answers[currentProblem.id] !== undefined) {
|
||||
event.preventDefault();
|
||||
moveProblem(currentIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setShowLeaveConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [problemSet, currentProblem, currentIndex, answers, showLeaveConfirm, showSubmitConfirm]);
|
||||
|
||||
const flushProblemTime = (problemId?: number) => {
|
||||
if (!problemId || !problemEnteredAtRef.current) return;
|
||||
const now = Date.now();
|
||||
const elapsed = Math.max(0, now - problemEnteredAtRef.current);
|
||||
problemTimeMsRef.current[problemId] =
|
||||
(problemTimeMsRef.current[problemId] ?? 0) + elapsed;
|
||||
problemEnteredAtRef.current = now;
|
||||
};
|
||||
|
||||
const moveProblem = (nextIndex: number) => {
|
||||
if (!problemSet || !currentProblem) return;
|
||||
if (nextIndex < 0 || nextIndex >= problems.length) return;
|
||||
|
||||
flushProblemTime(currentProblem.id);
|
||||
const nextProblem = problems[nextIndex];
|
||||
setCurrentIndex(nextIndex);
|
||||
setVisited((prev) => ({ ...prev, [nextProblem.id]: true }));
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
|
||||
const selectAnswer = (choice: number) => {
|
||||
if (!currentProblem) return;
|
||||
setAnswers((prev) => ({ ...prev, [currentProblem.id]: choice }));
|
||||
};
|
||||
|
||||
const toggleFlag = (problemId: number) => {
|
||||
setFlagged((prev) => ({ ...prev, [problemId]: !prev[problemId] }));
|
||||
};
|
||||
|
||||
const finishExam = async (autoSubmit = false) => {
|
||||
if (!problemSet || !currentProblem || submittedRef.current) return;
|
||||
|
||||
submittedRef.current = true;
|
||||
setSubmitting(true);
|
||||
flushProblemTime(currentProblem.id);
|
||||
|
||||
try {
|
||||
const totalTimeSpent = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - examStartedAtRef.current) / 1000),
|
||||
);
|
||||
|
||||
const summary = await submitProblemSetStudy({
|
||||
problemSetId: problemSet.id,
|
||||
totalTimeSpent,
|
||||
answers: problemSet.problems.map((problem) => ({
|
||||
problemId: problem.id,
|
||||
chosenAnswer: answers[problem.id] ?? null,
|
||||
timeSpent: toWholeSeconds(problemTimeMsRef.current[problem.id] ?? 0),
|
||||
})),
|
||||
});
|
||||
|
||||
storeExamResult(problemSet, summary);
|
||||
if (autoSubmit) {
|
||||
showToast({ message: '시험 시간이 종료되어 자동 제출되었어', variant: 'warning' });
|
||||
}
|
||||
router.replace(`/study/exam/${problemSet.id}/result`);
|
||||
} catch {
|
||||
submittedRef.current = false;
|
||||
setSubmitting(false);
|
||||
showToast({ message: '시험 제출에 실패했어. 다시 시도해줘.', variant: 'danger' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<StateScreen>
|
||||
<StateCard>
|
||||
<Icon name="clock" size={28} />
|
||||
문제집을 준비하는 중...
|
||||
</StateCard>
|
||||
</StateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !problemSet || !currentProblem) {
|
||||
return (
|
||||
<StateScreen>
|
||||
<StateCard>
|
||||
<Icon name="info" size={28} />
|
||||
{error ?? '문제집을 찾지 못했어.'}
|
||||
<RetryButton type="button" onClick={() => void loadProblemSet()}>
|
||||
다시 시도
|
||||
</RetryButton>
|
||||
</StateCard>
|
||||
</StateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const timerDanger = remainingSeconds <= 5 * 60;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<TopBar>
|
||||
<BackButton type="button" onClick={() => setShowLeaveConfirm(true)}>
|
||||
<Icon name="arrow-left" size={18} />
|
||||
뒤로가기
|
||||
</BackButton>
|
||||
|
||||
<TopTitle>{problemSet.title}</TopTitle>
|
||||
|
||||
<Timer $danger={timerDanger}>
|
||||
<Icon name="clock" size={18} />
|
||||
{formatClock(remainingSeconds)}
|
||||
</Timer>
|
||||
</TopBar>
|
||||
|
||||
<Content>
|
||||
<MainArea>
|
||||
{passage && (
|
||||
<PassageCard>
|
||||
<PassageHeader>
|
||||
<span>공통 지문 [{passage.startNumber}~{passage.endNumber}]</span>
|
||||
</PassageHeader>
|
||||
<PassageBody>{passage.bodyText}</PassageBody>
|
||||
</PassageCard>
|
||||
)}
|
||||
|
||||
<ProblemCard>
|
||||
<ProblemHeader>
|
||||
<ProblemNumber>{currentProblem.number}번</ProblemNumber>
|
||||
<FlagButton
|
||||
type="button"
|
||||
$active={!!flagged[currentProblem.id]}
|
||||
onClick={() => toggleFlag(currentProblem.id)}
|
||||
>
|
||||
<Icon
|
||||
name="flag"
|
||||
size={16}
|
||||
weight={flagged[currentProblem.id] ? 'fill' : 'regular'}
|
||||
/>
|
||||
★ 표시
|
||||
</FlagButton>
|
||||
</ProblemHeader>
|
||||
|
||||
<ProblemTitle>{currentProblem.title}</ProblemTitle>
|
||||
<ProblemBody>{currentProblem.bodyText ?? '본문 텍스트가 비어 있어.'}</ProblemBody>
|
||||
|
||||
<ChoiceList>
|
||||
{['1', '2', '3', '4', '5'].map((key) => {
|
||||
const choiceText = currentProblem.choices?.[key] ?? '';
|
||||
const choiceNumber = Number(key);
|
||||
const selected = answers[currentProblem.id] === choiceNumber;
|
||||
|
||||
return (
|
||||
<ChoiceButton
|
||||
key={key}
|
||||
type="button"
|
||||
$selected={selected}
|
||||
onClick={() => selectAnswer(choiceNumber)}
|
||||
>
|
||||
<ChoiceBubble $selected={selected}>{key}</ChoiceBubble>
|
||||
<ChoiceText>{choiceText || `${key}번 선택지`}</ChoiceText>
|
||||
</ChoiceButton>
|
||||
);
|
||||
})}
|
||||
</ChoiceList>
|
||||
</ProblemCard>
|
||||
|
||||
<BottomBar>
|
||||
<NavButton
|
||||
type="button"
|
||||
onClick={() => moveProblem(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<Icon name="caret-left" size={16} />
|
||||
이전 문제
|
||||
</NavButton>
|
||||
|
||||
<CenterActions>
|
||||
<SubtleButton type="button" onClick={() => moveProblem(currentIndex + 1)}>
|
||||
건너뛰기
|
||||
</SubtleButton>
|
||||
<MobileOnlyButton type="button" onClick={() => setMobileNavOpen(true)}>
|
||||
답안 번호판
|
||||
</MobileOnlyButton>
|
||||
</CenterActions>
|
||||
|
||||
<FinishButton type="button" onClick={() => setShowSubmitConfirm(true)}>
|
||||
시험 종료
|
||||
</FinishButton>
|
||||
</BottomBar>
|
||||
</MainArea>
|
||||
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarTitle>문항 이동</SidebarTitle>
|
||||
<SidebarMeta>
|
||||
{answeredCount}/{problems.length} 완료
|
||||
</SidebarMeta>
|
||||
</SidebarHeader>
|
||||
|
||||
<Grid>
|
||||
{problems.map((problem, index) => {
|
||||
const answered = answers[problem.id] !== undefined && answers[problem.id] !== null;
|
||||
const skipped = visited[problem.id] && !answered;
|
||||
|
||||
return (
|
||||
<GridButton
|
||||
key={problem.id}
|
||||
type="button"
|
||||
$current={index === currentIndex}
|
||||
$answered={answered}
|
||||
$flagged={!!flagged[problem.id]}
|
||||
$skipped={skipped}
|
||||
onClick={() => moveProblem(index)}
|
||||
>
|
||||
{problem.number}
|
||||
</GridButton>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
<Legend>
|
||||
<LegendRow>
|
||||
<LegendDot $tone="answered" />
|
||||
답안 완료
|
||||
</LegendRow>
|
||||
<LegendRow>
|
||||
<LegendDot $tone="flagged" />
|
||||
표시함
|
||||
</LegendRow>
|
||||
<LegendRow>
|
||||
<LegendDot $tone="skipped" />
|
||||
건너뜀
|
||||
</LegendRow>
|
||||
</Legend>
|
||||
</Sidebar>
|
||||
</Content>
|
||||
|
||||
<MobileDrawer $open={mobileNavOpen}>
|
||||
<DrawerHeader>
|
||||
<SidebarTitle>문항 이동</SidebarTitle>
|
||||
<DrawerClose type="button" onClick={() => setMobileNavOpen(false)}>
|
||||
닫기
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<Grid>
|
||||
{problems.map((problem, index) => {
|
||||
const answered = answers[problem.id] !== undefined && answers[problem.id] !== null;
|
||||
const skipped = visited[problem.id] && !answered;
|
||||
|
||||
return (
|
||||
<GridButton
|
||||
key={problem.id}
|
||||
type="button"
|
||||
$current={index === currentIndex}
|
||||
$answered={answered}
|
||||
$flagged={!!flagged[problem.id]}
|
||||
$skipped={skipped}
|
||||
onClick={() => moveProblem(index)}
|
||||
>
|
||||
{problem.number}
|
||||
</GridButton>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</MobileDrawer>
|
||||
<DrawerBackdrop $open={mobileNavOpen} onClick={() => setMobileNavOpen(false)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={showLeaveConfirm && !submitting}
|
||||
title="시험을 나갈까요?"
|
||||
body="진행 중인 답안은 제출 전까지 저장되지 않아. 지금 나가면 현재 시험은 종료돼."
|
||||
confirmLabel="나가기"
|
||||
cancelLabel="계속 풀기"
|
||||
tone="danger"
|
||||
onCancel={() => setShowLeaveConfirm(false)}
|
||||
onConfirm={() => router.push('/study')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showSubmitConfirm && !submitting}
|
||||
title="시험을 제출할까요?"
|
||||
body={`${answeredCount} / ${problems.length} 문항 답안 완료. 제출하시겠어요?`}
|
||||
confirmLabel={submitting ? '제출 중...' : '제출하기'}
|
||||
cancelLabel="계속 풀기"
|
||||
onCancel={() => setShowSubmitConfirm(false)}
|
||||
onConfirm={() => void finishExam(false)}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function toWholeSeconds(ms: number): number {
|
||||
if (ms <= 0) return 0;
|
||||
return Math.max(1, Math.round(ms / 1000));
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(79, 70, 229, 0.2), transparent 24%),
|
||||
linear-gradient(180deg, #0d1018 0%, #131724 100%);
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const TopBar = styled.header`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) 180px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 72px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid ${theme.color.borderSoftAlpha};
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(15, 15, 20, 0.88);
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const BackButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const TopTitle = styled.h1`
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
color: ${theme.color.textBright};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-column: 1 / -1;
|
||||
order: 3;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Timer = styled.div<{ $danger: boolean }>`
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
font-family: ${theme.font.mono};
|
||||
color: ${({ $danger }) => ($danger ? theme.color.danger : theme.color.textBright)};
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 24px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.desktop}) {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainArea = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const Surface = css`
|
||||
background: ${theme.color.surfaceDeep};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 24px;
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
`;
|
||||
|
||||
const PassageCard = styled.section`
|
||||
${Surface};
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const PassageHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
color: #c7d2fe;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const PassageBody = styled.div`
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.9;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const ProblemCard = styled.section`
|
||||
${Surface};
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const ProblemHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
`;
|
||||
|
||||
const ProblemNumber = styled.div`
|
||||
font-size: 28px;
|
||||
font-family: ${theme.font.display};
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const FlagButton = styled.button<{ $active: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
border: 1px solid
|
||||
${({ $active }) =>
|
||||
$active ? 'rgba(245, 158, 11, 0.44)' : theme.color.borderSoftAlpha};
|
||||
background: ${({ $active }) =>
|
||||
$active ? 'rgba(245, 158, 11, 0.12)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
color: ${({ $active }) => ($active ? theme.color.warning : theme.color.textSub)};
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ProblemTitle = styled.h2`
|
||||
margin-bottom: 14px;
|
||||
font-size: 22px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const ProblemBody = styled.div`
|
||||
margin-bottom: 24px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.95;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const ChoiceList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ChoiceButton = styled.button<{ $selected: boolean }>`
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid
|
||||
${({ $selected }) =>
|
||||
$selected ? 'rgba(129, 140, 248, 0.44)' : theme.color.borderSoftAlpha};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.16)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
text-align: left;
|
||||
transition: 0.15s ease;
|
||||
`;
|
||||
|
||||
const ChoiceBubble = styled.span<{ $selected: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid
|
||||
${({ $selected }) => ($selected ? theme.color.brandIndigo : theme.color.borderSoftAlpha)};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? theme.color.brandGradient : 'rgba(255, 255, 255, 0.04)'};
|
||||
color: ${theme.color.textBright};
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const ChoiceText = styled.span`
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
color: ${theme.color.textMain};
|
||||
`;
|
||||
|
||||
const BottomBar = styled.div`
|
||||
${Surface};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
const NavButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: ${theme.color.textBright};
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
`;
|
||||
|
||||
const CenterActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const SubtleButton = styled.button`
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const MobileOnlyButton = styled(SubtleButton)`
|
||||
display: none;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.desktop}) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const FinishButton = styled.button`
|
||||
min-height: 52px;
|
||||
padding: 0 20px;
|
||||
border-radius: 16px;
|
||||
background: ${theme.color.brandGradient};
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
`;
|
||||
|
||||
const Sidebar = styled.aside`
|
||||
${Surface};
|
||||
position: sticky;
|
||||
top: 96px;
|
||||
height: fit-content;
|
||||
padding: 18px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.desktop}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
|
||||
const SidebarTitle = styled.h3`
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const SidebarMeta = styled.span`
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const GridButton = styled.button<{
|
||||
$current: boolean;
|
||||
$answered: boolean;
|
||||
$flagged: boolean;
|
||||
$skipped: boolean;
|
||||
}>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 14px;
|
||||
border: 1px solid
|
||||
${({ $flagged, $answered }) =>
|
||||
$flagged
|
||||
? 'rgba(245, 158, 11, 0.7)'
|
||||
: $answered
|
||||
? 'rgba(99, 102, 241, 0.45)'
|
||||
: theme.color.borderSoftAlpha};
|
||||
background: ${({ $answered, $skipped }) =>
|
||||
$answered
|
||||
? 'rgba(79, 70, 229, 0.18)'
|
||||
: $skipped
|
||||
? 'rgba(255, 255, 255, 0.04)'
|
||||
: 'rgba(255, 255, 255, 0.02)'};
|
||||
color: ${({ $skipped }) => ($skipped ? theme.color.textMute : theme.color.textBright)};
|
||||
font-weight: 800;
|
||||
|
||||
${({ $current }) =>
|
||||
$current &&
|
||||
css`
|
||||
box-shadow: inset 0 0 0 2px ${theme.color.brandIndigo}, ${theme.shadow.glowIndigo};
|
||||
`}
|
||||
`;
|
||||
|
||||
const Legend = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const LegendRow = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const LegendDot = styled.span<{ $tone: 'answered' | 'flagged' | 'skipped' }>`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $tone }) =>
|
||||
$tone === 'answered'
|
||||
? 'rgba(79, 70, 229, 0.65)'
|
||||
: $tone === 'flagged'
|
||||
? 'rgba(245, 158, 11, 0.9)'
|
||||
: 'rgba(255,255,255,0.22)'};
|
||||
`;
|
||||
|
||||
const MobileDrawer = styled.div<{ $open: boolean }>`
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
|
||||
border-top-left-radius: 24px;
|
||||
border-top-right-radius: 24px;
|
||||
background: ${theme.color.surfaceDeep};
|
||||
border-top: 1px solid ${theme.color.borderSoftAlpha};
|
||||
transform: translateY(${({ $open }) => ($open ? '0' : '100%')});
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
@media (min-width: ${theme.breakpoint.desktop}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const DrawerBackdrop = styled.button<{ $open: boolean }>`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: ${({ $open }) => ($open ? 1 : 0)};
|
||||
pointer-events: ${({ $open }) => ($open ? 'auto' : 'none')};
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
@media (min-width: ${theme.breakpoint.desktop}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const DrawerHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const DrawerClose = styled.button`
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const StateScreen = styled.div`
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: ${theme.color.bgDeep};
|
||||
`;
|
||||
|
||||
const StateCard = styled.div`
|
||||
${Surface};
|
||||
min-width: min(440px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const RetryButton = styled.button`
|
||||
min-height: 46px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
background: ${theme.color.brandGradient};
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
`;
|
||||
339
frontend/src/app/study/exam/[problemSetId]/result/page.tsx
Normal file
339
frontend/src/app/study/exam/[problemSetId]/result/page.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { readExamResult } from '@/components/exam/shared';
|
||||
import { hasToken } from '@/lib/auth';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export default function ExamResultPage() {
|
||||
const params = useParams<{ problemSetId: string }>();
|
||||
const router = useRouter();
|
||||
const problemSetId = Number(params.problemSetId);
|
||||
const [result, setResult] = useState<ReturnType<typeof readExamResult>>(null);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasToken()) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setResult(readExamResult(problemSetId));
|
||||
setChecked(true);
|
||||
}, [problemSetId, router]);
|
||||
|
||||
const sortedResults = useMemo(
|
||||
() => [...(result?.summary.results ?? [])].sort((a, b) => a.number - b.number),
|
||||
[result],
|
||||
);
|
||||
|
||||
if (!checked) {
|
||||
return (
|
||||
<StateScreen>
|
||||
<StateCard>결과를 불러오는 중...</StateCard>
|
||||
</StateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<StateScreen>
|
||||
<StateCard>
|
||||
<Icon name="info" size={28} />
|
||||
방금 제출한 시험 결과를 찾지 못했어.
|
||||
<ButtonRow>
|
||||
<GhostButton type="button" onClick={() => router.push('/study')}>
|
||||
다른 시험 풀기
|
||||
</GhostButton>
|
||||
<PrimaryButton type="button" onClick={() => router.push('/dashboard')}>
|
||||
대시보드로 돌아가기
|
||||
</PrimaryButton>
|
||||
</ButtonRow>
|
||||
</StateCard>
|
||||
</StateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Wrap>
|
||||
<HeroCard>
|
||||
<HeroEyebrow>{result.problemSet.subjectName} 모의 시험 결과</HeroEyebrow>
|
||||
<HeroTitle>{result.problemSet.title}</HeroTitle>
|
||||
<HeroScore>
|
||||
{result.summary.correct}/{result.summary.total} 맞춤 · {result.summary.accuracy}%
|
||||
</HeroScore>
|
||||
<HeroSub>
|
||||
각 문항이 학습 기록에 저장되어 복습 큐에 추가되었어요.
|
||||
</HeroSub>
|
||||
|
||||
<ChipRow>
|
||||
<Chip $tone="correct">정답 {result.summary.correct}</Chip>
|
||||
<Chip $tone="incorrect">오답 {result.summary.incorrect}</Chip>
|
||||
<Chip $tone="skipped">건너뜀 {result.summary.skipped}</Chip>
|
||||
</ChipRow>
|
||||
|
||||
<ButtonRow>
|
||||
<GhostButton type="button" onClick={() => router.push('/study')}>
|
||||
다른 시험 풀기
|
||||
</GhostButton>
|
||||
<PrimaryButton type="button" onClick={() => router.push('/dashboard')}>
|
||||
대시보드로 돌아가기
|
||||
</PrimaryButton>
|
||||
</ButtonRow>
|
||||
</HeroCard>
|
||||
|
||||
<ReviewCard>
|
||||
<SectionTitle>문항별 결과</SectionTitle>
|
||||
<ReviewList>
|
||||
{sortedResults.map((item) => (
|
||||
<ReviewItem key={item.problemId}>
|
||||
<ReviewTop>
|
||||
<ReviewNumber>{item.number}번</ReviewNumber>
|
||||
<ReviewBadge $result={item.result}>
|
||||
{item.result === 'correct'
|
||||
? '정답'
|
||||
: item.result === 'incorrect'
|
||||
? '오답'
|
||||
: item.result === 'skipped'
|
||||
? '건너뜀'
|
||||
: '채점 보류'}
|
||||
</ReviewBadge>
|
||||
</ReviewTop>
|
||||
|
||||
<AnswerLine>
|
||||
내 답: {item.chosenAnswer ?? '미응답'} / 정답: {item.correctAnswer ?? '미등록'}
|
||||
</AnswerLine>
|
||||
|
||||
{item.bodyText && <Preview>{item.bodyText}</Preview>}
|
||||
</ReviewItem>
|
||||
))}
|
||||
</ReviewList>
|
||||
</ReviewCard>
|
||||
</Wrap>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
padding: 24px 16px 48px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.2), transparent 24%),
|
||||
linear-gradient(180deg, #0f1016 0%, #141824 100%);
|
||||
`;
|
||||
|
||||
const Wrap = styled.div`
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const Surface = css`
|
||||
background: ${theme.color.surfaceDeep};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 24px;
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
`;
|
||||
|
||||
const HeroCard = styled.section`
|
||||
${Surface};
|
||||
padding: 28px;
|
||||
`;
|
||||
|
||||
const HeroEyebrow = styled.div`
|
||||
color: #c7d2fe;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
`;
|
||||
|
||||
const HeroTitle = styled.h1`
|
||||
margin-top: 10px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 28px;
|
||||
line-height: 1.35;
|
||||
`;
|
||||
|
||||
const HeroScore = styled.div`
|
||||
margin-top: 18px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 40px;
|
||||
font-family: ${theme.font.display};
|
||||
font-weight: 900;
|
||||
`;
|
||||
|
||||
const HeroSub = styled.p`
|
||||
margin-top: 10px;
|
||||
color: ${theme.color.textSub};
|
||||
line-height: 1.7;
|
||||
`;
|
||||
|
||||
const ChipRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
`;
|
||||
|
||||
const Chip = styled.span<{ $tone: 'correct' | 'incorrect' | 'skipped' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
font-weight: 800;
|
||||
${({ $tone }) =>
|
||||
$tone === 'correct'
|
||||
? css`
|
||||
color: ${theme.color.success};
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.34);
|
||||
`
|
||||
: $tone === 'incorrect'
|
||||
? css`
|
||||
color: ${theme.color.danger};
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.34);
|
||||
`
|
||||
: css`
|
||||
color: ${theme.color.textSub};
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
`}
|
||||
`;
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const PrimaryButton = styled.button`
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
background: ${theme.color.brandGradient};
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const GhostButton = styled.button`
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
color: ${theme.color.textBright};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
`;
|
||||
|
||||
const ReviewCard = styled.section`
|
||||
${Surface};
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
margin-bottom: 16px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const ReviewList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ReviewItem = styled.article`
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
`;
|
||||
|
||||
const ReviewTop = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ReviewNumber = styled.div`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const ReviewBadge = styled.span<{ $result: 'correct' | 'incorrect' | 'partial' | 'skipped' }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
${({ $result }) =>
|
||||
$result === 'correct'
|
||||
? css`
|
||||
color: ${theme.color.success};
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
`
|
||||
: $result === 'incorrect'
|
||||
? css`
|
||||
color: ${theme.color.danger};
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
`
|
||||
: $result === 'skipped'
|
||||
? css`
|
||||
color: ${theme.color.textSub};
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
`
|
||||
: css`
|
||||
color: ${theme.color.warning};
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
`}
|
||||
`;
|
||||
|
||||
const AnswerLine = styled.div`
|
||||
margin-top: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const Preview = styled.p`
|
||||
margin-top: 12px;
|
||||
color: ${theme.color.textMain};
|
||||
line-height: 1.7;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StateScreen = styled.div`
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: ${theme.color.bgDeep};
|
||||
`;
|
||||
|
||||
const StateCard = styled.div`
|
||||
${Surface};
|
||||
min-width: min(420px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 28px;
|
||||
color: ${theme.color.textBright};
|
||||
text-align: center;
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
61
frontend/src/components/exam/shared.ts
Normal file
61
frontend/src/components/exam/shared.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import type { ProblemSetDetail, ProblemSetSubmissionResponse } from '@/lib/api';
|
||||
|
||||
export interface StoredExamResult {
|
||||
submittedAt: string;
|
||||
problemSet: {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
subjectName: string;
|
||||
};
|
||||
summary: ProblemSetSubmissionResponse;
|
||||
}
|
||||
|
||||
export function getExamDurationSeconds(subjectName: string): number {
|
||||
if (subjectName === '국어') return 80 * 60;
|
||||
if (subjectName === '영어') return 70 * 60;
|
||||
if (subjectName === '한국사') return 30 * 60;
|
||||
if (subjectName.includes('윤리') || subjectName.includes('탐구')) return 30 * 60;
|
||||
return 60 * 60;
|
||||
}
|
||||
|
||||
export function formatClock(totalSeconds: number): string {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return [hours, minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':');
|
||||
}
|
||||
|
||||
export function examResultStorageKey(problemSetId: number): string {
|
||||
return `reloop.exam.result.${problemSetId}`;
|
||||
}
|
||||
|
||||
export function storeExamResult(
|
||||
problemSet: Pick<ProblemSetDetail, 'id' | 'title' | 'year' | 'subjectName'>,
|
||||
summary: ProblemSetSubmissionResponse,
|
||||
) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const payload: StoredExamResult = {
|
||||
submittedAt: new Date().toISOString(),
|
||||
problemSet,
|
||||
summary,
|
||||
};
|
||||
window.sessionStorage.setItem(examResultStorageKey(problemSet.id), JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function readExamResult(problemSetId: number): StoredExamResult | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const raw = window.sessionStorage.getItem(examResultStorageKey(problemSetId));
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as StoredExamResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MagnifyingGlass, DownloadSimple, CalendarBlank, CalendarCheck, BellRinging, Bell, Sparkle,
|
||||
Minus, DotsThree, Translate, BookOpenText, SkipForward, Info, SignOut, Camera, Crown,
|
||||
Export, Gear, ArrowRight, type IconProps as PhIconProps, type IconWeight,
|
||||
Scales, Feather,
|
||||
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
// name → component map. 새 아이콘 추가 시 이 맵에만 등록.
|
||||
@@ -73,8 +73,13 @@ const ICON_MAP = {
|
||||
'export': Export,
|
||||
'gear': Gear,
|
||||
'arrow-right': ArrowRight,
|
||||
'arrow-left': ArrowLeft,
|
||||
'scales': Scales,
|
||||
'feather': Feather,
|
||||
'clock': Clock,
|
||||
'flag': Flag,
|
||||
'check-square': CheckSquare,
|
||||
'circle': Circle,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_MAP;
|
||||
|
||||
@@ -60,7 +60,9 @@ export interface ProblemSetSummary {
|
||||
year: number;
|
||||
subjectName: string;
|
||||
sourceUrl: string | null;
|
||||
_count: { problems: number };
|
||||
audioUrls?: string[] | null;
|
||||
_count?: { problems: number };
|
||||
problems?: Array<{ needsReview: boolean }>;
|
||||
}
|
||||
|
||||
export interface Problem {
|
||||
@@ -71,10 +73,23 @@ export interface Problem {
|
||||
difficulty: number;
|
||||
baseCorrectRate: number | null;
|
||||
topic: string | null;
|
||||
bodyText?: string | null;
|
||||
choices?: Record<string, string> | null;
|
||||
answerNumber?: number | null;
|
||||
passageId?: number | null;
|
||||
needsReview: boolean;
|
||||
}
|
||||
|
||||
export interface Passage {
|
||||
id: number;
|
||||
startNumber: number;
|
||||
endNumber: number;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
export interface ProblemSetDetail extends ProblemSetSummary {
|
||||
problems: Problem[];
|
||||
passages: Passage[];
|
||||
}
|
||||
|
||||
export interface Subject {
|
||||
@@ -175,3 +190,56 @@ export interface MasteryPathResponse {
|
||||
daysFromNow: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProblemSetAnswerPayload {
|
||||
problemId: number;
|
||||
chosenAnswer: number | null;
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
export interface ProblemSetSubmissionResult {
|
||||
problemId: number;
|
||||
number: number;
|
||||
title: string;
|
||||
bodyText: string | null;
|
||||
chosenAnswer: number | null;
|
||||
correctAnswer: number | null;
|
||||
result: StudyResult | 'skipped';
|
||||
studyLogId: number | null;
|
||||
}
|
||||
|
||||
export interface ProblemSetSubmissionResponse {
|
||||
total: number;
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
skipped: number;
|
||||
accuracy: number;
|
||||
totalTimeSpent: number;
|
||||
studyLogIds: number[];
|
||||
results: ProblemSetSubmissionResult[];
|
||||
}
|
||||
|
||||
export async function getProblemSets(params?: {
|
||||
year?: number;
|
||||
subjectName?: string;
|
||||
}) {
|
||||
const response = await api.get<ProblemSetSummary[]>('/problem-sets', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getProblemSet(id: number) {
|
||||
const response = await api.get<ProblemSetDetail>(`/problem-sets/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function submitProblemSetStudy(payload: {
|
||||
problemSetId: number;
|
||||
totalTimeSpent: number;
|
||||
answers: ProblemSetAnswerPayload[];
|
||||
}) {
|
||||
const response = await api.post<ProblemSetSubmissionResponse>(
|
||||
'/study-logs/from-problem-set',
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user