feat(detail): study log detail + review wiring + seeded S0 — 7G.3

This commit is contained in:
reloop
2026-04-12 07:21:53 +09:00
parent 7c5cdf4c64
commit 9068fcbd48
9 changed files with 863 additions and 77 deletions

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

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

View File

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

View File

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

View File

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