feat(detail): study log detail + review wiring + seeded S0 — 7G.3
This commit is contained in:
@@ -51,6 +51,7 @@ export const MAX_INTERVAL_DAYS = 60;
|
||||
export interface UpdateInput {
|
||||
previousS0: number | null;
|
||||
result: StudyResult;
|
||||
baseCorrectRate?: number;
|
||||
}
|
||||
|
||||
export interface ScheduleInput {
|
||||
@@ -76,7 +77,11 @@ export class PersonaForgetService {
|
||||
* Clamped to [0, 1].
|
||||
*/
|
||||
updateS0(input: UpdateInput): number {
|
||||
const prev = input.previousS0 ?? DEFAULT_INITIAL_S0;
|
||||
const prev =
|
||||
input.previousS0 ??
|
||||
(typeof input.baseCorrectRate === 'number'
|
||||
? seedInitialS0(input.baseCorrectRate)
|
||||
: DEFAULT_INITIAL_S0);
|
||||
switch (input.result) {
|
||||
case 'correct':
|
||||
return clamp(prev * 0.5 + 0.6, 0, 1);
|
||||
@@ -187,6 +192,9 @@ export class PersonaForgetService {
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
function seedInitialS0(baseCorrectRate: number): number {
|
||||
return clamp(baseCorrectRate * 0.8 + 0.15, 0.15, 0.95);
|
||||
}
|
||||
function logit(p: number): number {
|
||||
const eps = 1e-9;
|
||||
const q = Math.max(eps, Math.min(1 - eps, p));
|
||||
|
||||
@@ -91,6 +91,7 @@ export class ReviewsService {
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: existing?.s0 ?? null,
|
||||
result,
|
||||
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: {
|
||||
@@ -110,7 +111,11 @@ export class ReviewsService {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newS0 = this.forget.updateS0({ previousS0: null, result });
|
||||
newS0 = this.forget.updateS0({
|
||||
previousS0: null,
|
||||
result,
|
||||
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// d. Compute next schedule
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
@@ -128,6 +129,12 @@ class CreateFromProblemSetDto {
|
||||
answers: ProblemSetAnswerDto[];
|
||||
}
|
||||
|
||||
class UpdateStudyLogDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
@Controller('study-logs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StudyLogsController {
|
||||
@@ -155,4 +162,13 @@ export class StudyLogsController {
|
||||
one(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.svc.getOne(user.id, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateStudyLogDto,
|
||||
) {
|
||||
return this.svc.update(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,11 +248,74 @@ export class StudyLogsService {
|
||||
include: {
|
||||
subject: { select: { id: true, name: true, color: true } },
|
||||
tag: { select: { id: true, name: true } },
|
||||
problem: {
|
||||
include: {
|
||||
problemSet: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
year: true,
|
||||
examType: true,
|
||||
subjectName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
reviewSchedules: { orderBy: { scheduledAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!log) throw new NotFoundException();
|
||||
return log;
|
||||
return {
|
||||
id: log.id,
|
||||
studiedAt: log.studiedAt,
|
||||
result: log.result,
|
||||
memo: log.memo,
|
||||
timeSpent: log.timeSpent,
|
||||
difficulty: log.difficulty,
|
||||
baseCorrectRate: log.baseCorrectRate,
|
||||
subject: log.subject,
|
||||
tag: log.tag,
|
||||
problem: log.problem
|
||||
? {
|
||||
id: log.problem.id,
|
||||
number: log.problem.number,
|
||||
title: log.problem.title,
|
||||
bodyText: log.problem.bodyText,
|
||||
choices: log.problem.choices,
|
||||
answerNumber: log.problem.answerNumber,
|
||||
imageUrl: null,
|
||||
problemSet: log.problem.problemSet,
|
||||
}
|
||||
: null,
|
||||
reviewSchedules: log.reviewSchedules.map((review) => ({
|
||||
id: review.id,
|
||||
scheduledAt: review.scheduledAt,
|
||||
status: review.status,
|
||||
iteration: review.iteration,
|
||||
predictedP: review.predictedP,
|
||||
reviewedAt: review.reviewedAt,
|
||||
result: review.result,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: number, id: number, data: { memo?: string }) {
|
||||
const existing = await this.prisma.studyLog.findFirst({
|
||||
where: { id, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException();
|
||||
|
||||
return this.prisma.studyLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
memo: data.memo?.trim() ? data.memo : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
memo: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async validateCreateInput(
|
||||
@@ -316,6 +379,7 @@ export class StudyLogsService {
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: prevS0,
|
||||
result: input.result,
|
||||
baseCorrectRate: input.baseCorrectRate ?? undefined,
|
||||
});
|
||||
await tx.skillSnapshot.upsert({
|
||||
where: { userId_tagId: { userId, tagId: input.tagId } },
|
||||
@@ -333,7 +397,11 @@ export class StudyLogsService {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
s0 = this.forget.updateS0({ previousS0: null, result: input.result });
|
||||
s0 = this.forget.updateS0({
|
||||
previousS0: null,
|
||||
result: input.result,
|
||||
baseCorrectRate: input.baseCorrectRate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const schedule = this.forget.schedule({
|
||||
|
||||
@@ -363,26 +363,28 @@ function DashboardBody() {
|
||||
<SectionEmpty>최근 학습 기록이 아직 없어요.</SectionEmpty>
|
||||
) : (
|
||||
recentLogs.map((log) => (
|
||||
<RecentItem key={log.id}>
|
||||
<RecentLeft>
|
||||
<RecentStatus $result={log.result}>
|
||||
<Icon
|
||||
name={resultIcon(log.result)}
|
||||
weight="bold"
|
||||
size={12}
|
||||
color="currentColor"
|
||||
/>
|
||||
</RecentStatus>
|
||||
<RecentCopy>
|
||||
<RecentTitle>{log.title}</RecentTitle>
|
||||
<RecentSubtitle>
|
||||
{log.subject?.name ?? '과목 없음'}
|
||||
{log.tag?.name ? ` · ${log.tag.name}` : ''}
|
||||
</RecentSubtitle>
|
||||
</RecentCopy>
|
||||
</RecentLeft>
|
||||
<RecentTime>{timeAgo(log.studiedAt)}</RecentTime>
|
||||
</RecentItem>
|
||||
<RecentItemLink key={log.id} href={`/study-logs/${log.id}`}>
|
||||
<RecentItem>
|
||||
<RecentLeft>
|
||||
<RecentStatus $result={log.result}>
|
||||
<Icon
|
||||
name={resultIcon(log.result)}
|
||||
weight="bold"
|
||||
size={12}
|
||||
color="currentColor"
|
||||
/>
|
||||
</RecentStatus>
|
||||
<RecentCopy>
|
||||
<RecentTitle>{log.title}</RecentTitle>
|
||||
<RecentSubtitle>
|
||||
{log.subject?.name ?? '과목 없음'}
|
||||
{log.tag?.name ? ` · ${log.tag.name}` : ''}
|
||||
</RecentSubtitle>
|
||||
</RecentCopy>
|
||||
</RecentLeft>
|
||||
<RecentTime>{timeAgo(log.studiedAt)}</RecentTime>
|
||||
</RecentItem>
|
||||
</RecentItemLink>
|
||||
))
|
||||
)}
|
||||
</SectionBody>
|
||||
@@ -1195,6 +1197,11 @@ const MasteryNow = styled.span`
|
||||
font-size: 10px;
|
||||
`;
|
||||
|
||||
const RecentItemLink = styled(Link)`
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
const RecentItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
613
frontend/src/app/study-logs/[id]/page.tsx
Normal file
613
frontend/src/app/study-logs/[id]/page.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Button, Card, Textarea } from '@/components/ui/primitives';
|
||||
import { api, type ReviewStatus, type StudyLogDetail, type StudyResult } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export default function StudyLogDetailPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<StudyLogDetailBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StudyLogDetailBody() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const studyLogId = Number(params.id);
|
||||
const [detail, setDetail] = useState<StudyLogDetail | null>(null);
|
||||
const [memo, setMemo] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (!Number.isFinite(studyLogId)) {
|
||||
setError('잘못된 학습 기록 주소입니다.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<StudyLogDetail>(`/study-logs/${studyLogId}`);
|
||||
if (cancelled) return;
|
||||
setDetail(response.data);
|
||||
setMemo(response.data.memo ?? '');
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError('학습 기록 상세를 불러오지 못했어요.');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [studyLogId]);
|
||||
|
||||
const latestPredictedP = useMemo(() => {
|
||||
if (!detail || detail.reviewSchedules.length === 0) return null;
|
||||
return detail.reviewSchedules[detail.reviewSchedules.length - 1]?.predictedP ?? null;
|
||||
}, [detail]);
|
||||
|
||||
const saveMemo = async () => {
|
||||
if (!detail) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await api.patch<{ id: number; memo: string | null }>(
|
||||
`/study-logs/${detail.id}`,
|
||||
{ memo },
|
||||
);
|
||||
setDetail((prev) => (prev ? { ...prev, memo: response.data.memo } : prev));
|
||||
setMemo(response.data.memo ?? '');
|
||||
} catch {
|
||||
setError('메모 저장에 실패했어요.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <StateCard>학습 기록을 불러오는 중...</StateCard>;
|
||||
}
|
||||
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<StateCard>
|
||||
<Icon name="info" size={18} />
|
||||
{error ?? '학습 기록을 찾지 못했어요.'}
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
const problem = detail.problem;
|
||||
const problemSet = problem?.problemSet;
|
||||
const problemChoices = normalizeChoices(problem?.choices);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<HeaderCard>
|
||||
<HeaderTop>
|
||||
<div>
|
||||
<Eyebrow>
|
||||
{problem
|
||||
? `${problem.number}번 · ${problemSet?.year ?? '-'}학년도 ${problemSet?.examType ?? ''} ${problemSet?.subjectName ?? ''}`
|
||||
: '일반 학습 기록'}
|
||||
</Eyebrow>
|
||||
<Title>{problem?.title ?? '문제 정보가 없는 학습 기록'}</Title>
|
||||
<MetaLine>
|
||||
{problemSet?.title ?? detail.subject.name}
|
||||
{detail.tag ? ` · ${detail.tag.name}` : ''}
|
||||
</MetaLine>
|
||||
</div>
|
||||
{problem && problemSet && (
|
||||
<Link href={`/study/exam/${problemSet.id}?problem=${problem.number}`}>
|
||||
<ReplayButton as="span" $variant="secondary">
|
||||
다시 풀기
|
||||
<Icon name="arrow-right" size={16} />
|
||||
</ReplayButton>
|
||||
</Link>
|
||||
)}
|
||||
</HeaderTop>
|
||||
</HeaderCard>
|
||||
|
||||
<ContentGrid>
|
||||
<MainColumn>
|
||||
<SectionCard>
|
||||
<SectionTitle>문제 보기</SectionTitle>
|
||||
{problem?.imageUrl ? (
|
||||
<ProblemImage src={problem.imageUrl} alt={`${problem.number}번 문제 이미지`} />
|
||||
) : null}
|
||||
<ProblemBody>{problem?.bodyText ?? '문제 본문이 저장되어 있지 않습니다.'}</ProblemBody>
|
||||
{problemChoices.length > 0 && (
|
||||
<ChoiceList>
|
||||
{problemChoices.map((choice) => {
|
||||
const isCorrect = choice.number === problem?.answerNumber;
|
||||
return (
|
||||
<ChoiceItem key={choice.number} $correct={isCorrect}>
|
||||
<ChoiceNumber $correct={isCorrect}>{choice.number}</ChoiceNumber>
|
||||
<ChoiceText>{choice.text}</ChoiceText>
|
||||
{isCorrect ? (
|
||||
<ChoiceBadge>
|
||||
<Icon name="check-circle" weight="fill" size={14} />
|
||||
정답
|
||||
</ChoiceBadge>
|
||||
) : null}
|
||||
</ChoiceItem>
|
||||
);
|
||||
})}
|
||||
</ChoiceList>
|
||||
)}
|
||||
<InlineNote>
|
||||
선택한 답안은 현재 DB에 저장되지 않아 과거 기록에서는 복원할 수 없습니다.
|
||||
</InlineNote>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionTitle>메모</SectionTitle>
|
||||
<MemoArea
|
||||
value={memo}
|
||||
onChange={(event) => setMemo(event.target.value)}
|
||||
placeholder="이번 문제에서 헷갈렸던 포인트를 남겨두세요."
|
||||
/>
|
||||
<MemoActions>
|
||||
<SaveHint>{detail.memo ? '저장된 메모가 있습니다.' : '아직 메모가 없습니다.'}</SaveHint>
|
||||
<Button type="button" onClick={() => void saveMemo()} disabled={saving}>
|
||||
{saving ? '저장 중...' : '메모 저장'}
|
||||
</Button>
|
||||
</MemoActions>
|
||||
</SectionCard>
|
||||
</MainColumn>
|
||||
|
||||
<SideColumn>
|
||||
<SectionCard>
|
||||
<SectionTitle>학습 상태</SectionTitle>
|
||||
<StatsStrip>
|
||||
<StatTile>
|
||||
<StatLabel>결과</StatLabel>
|
||||
<ResultChip $result={detail.result}>
|
||||
<Icon name={resultIcon(detail.result)} size={14} weight="bold" />
|
||||
{resultLabel(detail.result)}
|
||||
</ResultChip>
|
||||
</StatTile>
|
||||
<StatTile>
|
||||
<StatLabel>소요 시간</StatLabel>
|
||||
<StatValue>{formatDuration(detail.timeSpent)}</StatValue>
|
||||
</StatTile>
|
||||
<StatTile>
|
||||
<StatLabel>난이도</StatLabel>
|
||||
<StatValue>{formatDifficulty(detail.difficulty)}</StatValue>
|
||||
</StatTile>
|
||||
<StatTile>
|
||||
<StatLabel>마지막 예상 정답률</StatLabel>
|
||||
<StatValue>{latestPredictedP === null ? '-' : `${Math.round(latestPredictedP * 100)}%`}</StatValue>
|
||||
</StatTile>
|
||||
</StatsStrip>
|
||||
<SubMeta>
|
||||
학습 시각 {formatDateTime(detail.studiedAt)}
|
||||
</SubMeta>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<SectionTitle>복습 타임라인</SectionTitle>
|
||||
<Timeline>
|
||||
{detail.reviewSchedules.length === 0 ? (
|
||||
<EmptyText>등록된 복습 일정이 없습니다.</EmptyText>
|
||||
) : (
|
||||
detail.reviewSchedules.map((review) => (
|
||||
<TimelineItem key={review.id}>
|
||||
<TimelineIcon $status={review.status}>
|
||||
<Icon name={reviewStatusIcon(review.status)} size={14} weight="bold" />
|
||||
</TimelineIcon>
|
||||
<TimelineBody>
|
||||
<TimelineTitle>
|
||||
{review.iteration + 1}회차 · {reviewStatusLabel(review.status)}
|
||||
</TimelineTitle>
|
||||
<TimelineMeta>
|
||||
예정 {formatDateTime(review.scheduledAt)}
|
||||
{review.reviewedAt ? ` · 완료 ${formatDateTime(review.reviewedAt)}` : ''}
|
||||
</TimelineMeta>
|
||||
<TimelineMeta>
|
||||
예상 정답률 {review.predictedP === null ? '-' : `${Math.round(review.predictedP * 100)}%`}
|
||||
{review.result ? ` · 결과 ${resultLabel(review.result)}` : ''}
|
||||
</TimelineMeta>
|
||||
</TimelineBody>
|
||||
</TimelineItem>
|
||||
))
|
||||
)}
|
||||
</Timeline>
|
||||
</SectionCard>
|
||||
</SideColumn>
|
||||
</ContentGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeChoices(
|
||||
choices: Record<string, string> | null | undefined,
|
||||
): Array<{ number: number; text: string }> {
|
||||
if (!choices) return [];
|
||||
return Object.entries(choices)
|
||||
.map(([key, text]) => ({ number: Number(key), text }))
|
||||
.filter((choice) => Number.isFinite(choice.number))
|
||||
.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
function resultLabel(result: StudyResult): string {
|
||||
if (result === 'correct') return '맞음';
|
||||
if (result === 'partial') return '부분';
|
||||
return '틀림';
|
||||
}
|
||||
|
||||
function resultIcon(result: StudyResult): 'check' | 'triangle' | 'x' {
|
||||
if (result === 'correct') return 'check';
|
||||
if (result === 'partial') return 'triangle';
|
||||
return 'x';
|
||||
}
|
||||
|
||||
function reviewStatusLabel(status: ReviewStatus): string {
|
||||
if (status === 'done') return '완료';
|
||||
if (status === 'skipped') return '건너뜀';
|
||||
if (status === 'expired') return '만료';
|
||||
return '대기';
|
||||
}
|
||||
|
||||
function reviewStatusIcon(status: ReviewStatus): 'clock' | 'check-circle' | 'skip' | 'x' {
|
||||
if (status === 'done') return 'check-circle';
|
||||
if (status === 'skipped') return 'skip';
|
||||
if (status === 'expired') return 'x';
|
||||
return 'clock';
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '-';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainSeconds = seconds % 60;
|
||||
if (minutes === 0) return `${remainSeconds}s`;
|
||||
if (remainSeconds === 0) return `${minutes}m`;
|
||||
return `${minutes}m ${remainSeconds}s`;
|
||||
}
|
||||
|
||||
function formatDifficulty(value: number): string {
|
||||
return `${Math.round(value * 100)} / 100`;
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const HeaderCard = styled(Card)`
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(16, 185, 129, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const HeaderTop = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const Eyebrow = styled.div`
|
||||
color: #86efac;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 8px 0 0;
|
||||
color: ${theme.color.textBright};
|
||||
font-family: ${theme.font.display};
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
`;
|
||||
|
||||
const MetaLine = styled.p`
|
||||
margin: 10px 0 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const ReplayButton = styled(Button)`
|
||||
min-width: 132px;
|
||||
`;
|
||||
|
||||
const ContentGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.9fr);
|
||||
gap: ${theme.space.lg};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.desktop}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const SideColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const SectionCard = styled(Card)`
|
||||
border-radius: 22px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(21, 21, 28, 0.88);
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
margin: 0 0 16px;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ProblemImage = styled.img`
|
||||
width: 100%;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const ProblemBody = styled.div`
|
||||
white-space: pre-wrap;
|
||||
color: ${theme.color.textMain};
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const ChoiceList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
`;
|
||||
|
||||
const ChoiceItem = styled.div<{ $correct: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid
|
||||
${({ $correct }) => ($correct ? 'rgba(16, 185, 129, 0.4)' : theme.color.borderSoftAlpha)};
|
||||
background: ${({ $correct }) =>
|
||||
$correct ? 'rgba(16, 185, 129, 0.12)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
`;
|
||||
|
||||
const ChoiceNumber = styled.div<{ $correct: boolean }>`
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
color: ${({ $correct }) => ($correct ? '#a7f3d0' : theme.color.textBright)};
|
||||
background: ${({ $correct }) => ($correct ? 'rgba(5, 150, 105, 0.38)' : theme.color.surface2)};
|
||||
`;
|
||||
|
||||
const ChoiceText = styled.div`
|
||||
flex: 1;
|
||||
color: ${theme.color.textMain};
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const ChoiceBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #86efac;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const InlineNote = styled.p`
|
||||
margin: 16px 0 0;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const MemoArea = styled(Textarea)`
|
||||
min-height: 140px;
|
||||
`;
|
||||
|
||||
const MemoActions = styled.div`
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
const SaveHint = styled.span`
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const StatsStrip = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StatTile = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
`;
|
||||
|
||||
const StatLabel = styled.span`
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const StatValue = styled.span`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ResultChip = styled.span<{ $result: StudyResult }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
${({ $result }) => {
|
||||
if ($result === 'correct') {
|
||||
return css`
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.24);
|
||||
color: #6ee7b7;
|
||||
`;
|
||||
}
|
||||
if ($result === 'partial') {
|
||||
return css`
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||
color: #fcd34d;
|
||||
`;
|
||||
}
|
||||
return css`
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
border: 1px solid rgba(244, 63, 94, 0.24);
|
||||
color: #fda4af;
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
const SubMeta = styled.div`
|
||||
margin-top: 14px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const Timeline = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
`;
|
||||
|
||||
const TimelineItem = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
|
||||
const TimelineIcon = styled.div<{ $status: ReviewStatus }>`
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
${({ $status }) => {
|
||||
if ($status === 'done') {
|
||||
return css`
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #6ee7b7;
|
||||
`;
|
||||
}
|
||||
if ($status === 'skipped') {
|
||||
return css`
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #fbbf24;
|
||||
`;
|
||||
}
|
||||
if ($status === 'expired') {
|
||||
return css`
|
||||
background: rgba(244, 63, 94, 0.14);
|
||||
color: #fda4af;
|
||||
`;
|
||||
}
|
||||
return css`
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
color: #c7d2fe;
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
const TimelineBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const TimelineTitle = styled.div`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const TimelineMeta = styled.div`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const EmptyText = styled.div`
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const StateCard = styled(Card)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 18px;
|
||||
`;
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
@@ -28,6 +28,7 @@ const TABLET_BREAKPOINT = 768;
|
||||
export default function ExamPage() {
|
||||
const params = useParams<{ problemSetId: string }>();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { showToast } = useToast();
|
||||
const problemSetId = Number(params.problemSetId);
|
||||
|
||||
@@ -86,11 +87,16 @@ export default function ExamPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProblemSet(problemSetId);
|
||||
const requestedProblemNumber = Number(searchParams.get('problem'));
|
||||
const requestedIndex = Number.isFinite(requestedProblemNumber)
|
||||
? data.problems.findIndex((problem) => problem.number === requestedProblemNumber)
|
||||
: -1;
|
||||
const initialIndex = requestedIndex >= 0 ? requestedIndex : 0;
|
||||
setProblemSet(data);
|
||||
setCurrentIndex(0);
|
||||
setCurrentIndex(initialIndex);
|
||||
setAnswers({});
|
||||
setFlagged({});
|
||||
setVisited(data.problems[0] ? { [data.problems[0].id]: true } : {});
|
||||
setVisited(data.problems[initialIndex] ? { [data.problems[initialIndex].id]: true } : {});
|
||||
setRemainingSeconds(getExamDurationSeconds(data.subjectName));
|
||||
examStartedAtRef.current = Date.now();
|
||||
problemEnteredAtRef.current = Date.now();
|
||||
@@ -114,7 +120,7 @@ export default function ExamPage() {
|
||||
return;
|
||||
}
|
||||
void loadProblemSet();
|
||||
}, [problemSetId, router]);
|
||||
}, [problemSetId, router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -304,36 +304,46 @@ function HistoryBody() {
|
||||
{pageItems.map((log) => (
|
||||
<TableBodyRow key={log.id}>
|
||||
<TableCell>
|
||||
<ItemTitle>{log.title}</ItemTitle>
|
||||
<ItemMeta>
|
||||
{log.subject?.name ?? '과목 미지정'}
|
||||
{log.tag?.name ? ` · ${log.tag.name}` : ''}
|
||||
</ItemMeta>
|
||||
<RowLink href={`/study-logs/${log.id}`}>
|
||||
<ItemTitle>{log.title}</ItemTitle>
|
||||
<ItemMeta>
|
||||
{log.subject?.name ?? '과목 미지정'}
|
||||
{log.tag?.name ? ` · ${log.tag.name}` : ''}
|
||||
</ItemMeta>
|
||||
</RowLink>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubjectTagStack>
|
||||
<SubjectBadge $color={log.subject?.color ?? theme.color.brandIndigo}>
|
||||
{log.subject?.name ?? '미분류'}
|
||||
</SubjectBadge>
|
||||
<TagText>{log.tag?.name ?? '태그 없음'}</TagText>
|
||||
</SubjectTagStack>
|
||||
<RowLink href={`/study-logs/${log.id}`}>
|
||||
<SubjectTagStack>
|
||||
<SubjectBadge $color={log.subject?.color ?? theme.color.brandIndigo}>
|
||||
{log.subject?.name ?? '미분류'}
|
||||
</SubjectBadge>
|
||||
<TagText>{log.tag?.name ?? '태그 없음'}</TagText>
|
||||
</SubjectTagStack>
|
||||
</RowLink>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DatePrimary>{formatDateTime(log.studiedAt)}</DatePrimary>
|
||||
<DateSecondary>{timeAgo(log.studiedAt)}</DateSecondary>
|
||||
<RowLink href={`/study-logs/${log.id}`}>
|
||||
<DatePrimary>{formatDateTime(log.studiedAt)}</DatePrimary>
|
||||
<DateSecondary>{timeAgo(log.studiedAt)}</DateSecondary>
|
||||
</RowLink>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ResultChip $result={log.result}>
|
||||
<Icon name={resultIcon(log.result)} size={14} weight="bold" />
|
||||
{resultLabel(log.result)}
|
||||
</ResultChip>
|
||||
<RowLink href={`/study-logs/${log.id}`}>
|
||||
<ResultChip $result={log.result}>
|
||||
<Icon name={resultIcon(log.result)} size={14} weight="bold" />
|
||||
{resultLabel(log.result)}
|
||||
</ResultChip>
|
||||
</RowLink>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ConfidenceValue
|
||||
title="자신감 데이터가 없어 학습 시간으로 대체 표시합니다."
|
||||
>
|
||||
{formatDuration(log.timeSpent)}
|
||||
</ConfidenceValue>
|
||||
<RowLink href={`/study-logs/${log.id}`}>
|
||||
<ConfidenceValue
|
||||
title="자신감 데이터가 없어 학습 시간으로 대체 표시합니다."
|
||||
>
|
||||
{formatDuration(log.timeSpent)}
|
||||
</ConfidenceValue>
|
||||
</RowLink>
|
||||
</TableCell>
|
||||
</TableBodyRow>
|
||||
))}
|
||||
@@ -343,35 +353,37 @@ function HistoryBody() {
|
||||
|
||||
<MobileCardList>
|
||||
{pageItems.map((log) => (
|
||||
<HistoryMobileCard key={log.id}>
|
||||
<MobileCardTop>
|
||||
<MobileItemTitle>{log.title}</MobileItemTitle>
|
||||
<ResultChip $result={log.result}>
|
||||
<Icon name={resultIcon(log.result)} size={14} weight="bold" />
|
||||
{resultLabel(log.result)}
|
||||
</ResultChip>
|
||||
</MobileCardTop>
|
||||
<MobileCardLink key={log.id} href={`/study-logs/${log.id}`}>
|
||||
<HistoryMobileCard>
|
||||
<MobileCardTop>
|
||||
<MobileItemTitle>{log.title}</MobileItemTitle>
|
||||
<ResultChip $result={log.result}>
|
||||
<Icon name={resultIcon(log.result)} size={14} weight="bold" />
|
||||
{resultLabel(log.result)}
|
||||
</ResultChip>
|
||||
</MobileCardTop>
|
||||
|
||||
<SubjectTagStack>
|
||||
<SubjectBadge $color={log.subject?.color ?? theme.color.brandIndigo}>
|
||||
{log.subject?.name ?? '미분류'}
|
||||
</SubjectBadge>
|
||||
<TagText>{log.tag?.name ?? '태그 없음'}</TagText>
|
||||
</SubjectTagStack>
|
||||
<SubjectTagStack>
|
||||
<SubjectBadge $color={log.subject?.color ?? theme.color.brandIndigo}>
|
||||
{log.subject?.name ?? '미분류'}
|
||||
</SubjectBadge>
|
||||
<TagText>{log.tag?.name ?? '태그 없음'}</TagText>
|
||||
</SubjectTagStack>
|
||||
|
||||
<MobileMetaRow>
|
||||
<MobileMetaLabel>학습일</MobileMetaLabel>
|
||||
<DatePrimary>{formatDateTime(log.studiedAt)}</DatePrimary>
|
||||
</MobileMetaRow>
|
||||
<MobileMetaRow>
|
||||
<MobileMetaLabel>경과</MobileMetaLabel>
|
||||
<DateSecondary>{timeAgo(log.studiedAt)}</DateSecondary>
|
||||
</MobileMetaRow>
|
||||
<MobileMetaRow>
|
||||
<MobileMetaLabel>기록</MobileMetaLabel>
|
||||
<ConfidenceValue>{formatDuration(log.timeSpent)}</ConfidenceValue>
|
||||
</MobileMetaRow>
|
||||
</HistoryMobileCard>
|
||||
<MobileMetaRow>
|
||||
<MobileMetaLabel>학습일</MobileMetaLabel>
|
||||
<DatePrimary>{formatDateTime(log.studiedAt)}</DatePrimary>
|
||||
</MobileMetaRow>
|
||||
<MobileMetaRow>
|
||||
<MobileMetaLabel>경과</MobileMetaLabel>
|
||||
<DateSecondary>{timeAgo(log.studiedAt)}</DateSecondary>
|
||||
</MobileMetaRow>
|
||||
<MobileMetaRow>
|
||||
<MobileMetaLabel>기록</MobileMetaLabel>
|
||||
<ConfidenceValue>{formatDuration(log.timeSpent)}</ConfidenceValue>
|
||||
</MobileMetaRow>
|
||||
</HistoryMobileCard>
|
||||
</MobileCardLink>
|
||||
))}
|
||||
</MobileCardList>
|
||||
|
||||
@@ -836,6 +848,11 @@ const MobileCardList = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileCardLink = styled(Link)`
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
const HistoryMobileCard = styled.article`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -907,6 +924,12 @@ const TableCell = styled.td`
|
||||
min-height: 56px;
|
||||
`;
|
||||
|
||||
const RowLink = styled(Link)`
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
const ItemTitle = styled.div`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 14px;
|
||||
|
||||
@@ -122,10 +122,50 @@ export interface StudyLog {
|
||||
id: number;
|
||||
scheduledAt: string;
|
||||
status: ReviewStatus;
|
||||
iteration?: number;
|
||||
reviewedAt?: string | null;
|
||||
result?: StudyResult | null;
|
||||
predictedP: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StudyLogDetail {
|
||||
id: number;
|
||||
studiedAt: string;
|
||||
result: StudyResult;
|
||||
memo: string | null;
|
||||
timeSpent: number | null;
|
||||
difficulty: number;
|
||||
baseCorrectRate: number | null;
|
||||
subject: { id: number; name: string; color: string };
|
||||
tag: { id: number; name: string } | null;
|
||||
problem: {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
bodyText: string | null;
|
||||
choices: Record<string, string> | null;
|
||||
answerNumber: number | null;
|
||||
imageUrl: string | null;
|
||||
problemSet: {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
examType: string;
|
||||
subjectName: string;
|
||||
};
|
||||
} | null;
|
||||
reviewSchedules: Array<{
|
||||
id: number;
|
||||
scheduledAt: string;
|
||||
status: ReviewStatus;
|
||||
iteration: number;
|
||||
predictedP: number | null;
|
||||
reviewedAt: string | null;
|
||||
result: StudyResult | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: number;
|
||||
studyLogId: number;
|
||||
|
||||
Reference in New Issue
Block a user