feat(study): mock exam flow from KICE problem sets — 7F.2

This commit is contained in:
reloop
2026-04-12 05:45:50 +09:00
parent cbc3af2a6b
commit 31d62fbe33
9 changed files with 2193 additions and 774 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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';
}

View 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;
`;

View 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

View 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;
}
}

View File

@@ -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;

View File

@@ -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;
}