feat(phase6): mastery-path API + stats UI 재설계 + Pretendard + 드래그 방지
트랙 1 — Pretendard 웹폰트 확실히 로드 - frontend/src/app/layout.tsx: Pretendard Variable CDN link 추가 - frontend/src/styles/theme.ts: font.sans 에 'Pretendard Variable' 우선 트랙 3 — 통계 재설계 (까먹음 → 마스터리 경로) - backend/src/stats/stats.service.ts: masteryPath() 추가. 이번 복습부터 계속 correct 를 가정한 best-case 궤적을 10회차까지 시뮬레이션해서 각 회차별 s0/p/daysFromNow 를 반환. P >= targetP 도달 시 iterationsToTarget + daysToTarget 도 함께 전달. - backend/src/stats/stats.controller.ts: GET /api/stats/mastery-path 추가. - frontend/src/lib/api.ts: MasteryPathResponse 타입 + ForgetCurveResponse 의 tag.subject 를 실제 select 에 맞춰 좁힘. - frontend/src/components/charts/MasteryPathChart.tsx: recharts LineChart 로 X=iteration, Y=P% + 목표 수평선 + S₀ 보조 라인. - frontend/src/app/stats/page.tsx: [마스터리 경로 | 망각 곡선] 토글. 마스터리 뷰는 'N회차 + K일 뒤 마스터 예상' 카피 + 회차별 요약 테이블. 트랙 5 — 드래그/선택 기본 금지 + 예외 - frontend/src/styles/GlobalStyle.ts: body 에 user-select:none + input/textarea/[data-selectable='true'] 만 예외 허용. img 드래그 금지. - frontend/src/components/ui/primitives.tsx: Selectable 컴포넌트 추가. - review/page.tsx, review/history/page.tsx, study/history/page.tsx: 문제 제목과 메모에 data-selectable 부여 (검색용 복사 허용).
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
@@ -32,6 +32,19 @@ class ForgetCurveQuery {
|
|||||||
steps?: number;
|
steps?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MasteryPathQuery {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
tagId: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.1)
|
||||||
|
@Max(0.99)
|
||||||
|
targetP?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('stats')
|
@Controller('stats')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class StatsController {
|
export class StatsController {
|
||||||
@@ -42,6 +55,11 @@ export class StatsController {
|
|||||||
return this.svc.forgetCurve(user.id, q.tagId, q.days, q.steps);
|
return this.svc.forgetCurve(user.id, q.tagId, q.days, q.steps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('mastery-path')
|
||||||
|
masteryPath(@CurrentUser() user: AuthUser, @Query() q: MasteryPathQuery) {
|
||||||
|
return this.svc.masteryPath(user.id, q.tagId, q.targetP);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('subjects')
|
@Get('subjects')
|
||||||
subjects(@CurrentUser() user: AuthUser) {
|
subjects(@CurrentUser() user: AuthUser) {
|
||||||
return this.svc.bySubject(user.id);
|
return this.svc.bySubject(user.id);
|
||||||
|
|||||||
@@ -65,6 +65,123 @@ export class StatsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mastery path — simulate a best-case trajectory where every upcoming review
|
||||||
|
* is answered correctly, so the user can see **"in N reviews / K days, this
|
||||||
|
* tag becomes mine"** instead of only the decay curve.
|
||||||
|
*/
|
||||||
|
async masteryPath(userId: number, tagId: number, targetP = 0.9) {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
const snapshot = await this.prisma.skillSnapshot.findUnique({
|
||||||
|
where: { userId_tagId: { userId, tagId } },
|
||||||
|
include: {
|
||||||
|
tag: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
subject: { select: { id: true, name: true, color: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!snapshot) throw new NotFoundException('no snapshot for this tag');
|
||||||
|
|
||||||
|
const recentLog = await this.prisma.studyLog.findFirst({
|
||||||
|
where: { userId, tagId },
|
||||||
|
orderBy: { studiedAt: 'desc' },
|
||||||
|
});
|
||||||
|
const D =
|
||||||
|
recentLog?.baseCorrectRate !== null &&
|
||||||
|
recentLog?.baseCorrectRate !== undefined
|
||||||
|
? 1 - recentLog.baseCorrectRate
|
||||||
|
: recentLog?.difficulty ?? 0.5;
|
||||||
|
|
||||||
|
const MAX_ITERATIONS = 10;
|
||||||
|
const startTime = new Date();
|
||||||
|
const points: Array<{
|
||||||
|
iteration: number;
|
||||||
|
scheduledAt: string;
|
||||||
|
s0: number;
|
||||||
|
p: number;
|
||||||
|
daysFromNow: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Iteration 0 — current state at t=now
|
||||||
|
const currentP = this.forget.predictP({
|
||||||
|
s0: snapshot.s0,
|
||||||
|
persona: user.persona,
|
||||||
|
difficulty: D,
|
||||||
|
lastUpdatedAt: snapshot.lastUpdatedAt,
|
||||||
|
at: startTime,
|
||||||
|
});
|
||||||
|
points.push({
|
||||||
|
iteration: 0,
|
||||||
|
scheduledAt: startTime.toISOString(),
|
||||||
|
s0: snapshot.s0,
|
||||||
|
p: currentP,
|
||||||
|
daysFromNow: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentS0 = snapshot.s0;
|
||||||
|
let currentTime = startTime;
|
||||||
|
let reached = currentP >= targetP;
|
||||||
|
|
||||||
|
for (let i = 1; i <= MAX_ITERATIONS && !reached; i++) {
|
||||||
|
// Best-case assumption: this review is answered correctly.
|
||||||
|
currentS0 = this.forget.updateS0({ previousS0: currentS0, result: 'correct' });
|
||||||
|
|
||||||
|
// Next review is scheduled when P falls back to the persona threshold.
|
||||||
|
const sched = this.forget.schedule({
|
||||||
|
s0: currentS0,
|
||||||
|
persona: user.persona,
|
||||||
|
intensity: user.reviewIntensity,
|
||||||
|
difficulty: D,
|
||||||
|
lastUpdatedAt: currentTime,
|
||||||
|
now: currentTime,
|
||||||
|
});
|
||||||
|
currentTime = sched.scheduledAt;
|
||||||
|
|
||||||
|
// P at the moment of review (right after updateS0, dtDays=0)
|
||||||
|
const pAtReview = this.forget.predictP({
|
||||||
|
s0: currentS0,
|
||||||
|
persona: user.persona,
|
||||||
|
difficulty: D,
|
||||||
|
lastUpdatedAt: currentTime,
|
||||||
|
at: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
iteration: i,
|
||||||
|
scheduledAt: currentTime.toISOString(),
|
||||||
|
s0: currentS0,
|
||||||
|
p: pAtReview,
|
||||||
|
daysFromNow: (currentTime.getTime() - startTime.getTime()) / 86_400_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pAtReview >= targetP) reached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
return {
|
||||||
|
tag: snapshot.tag,
|
||||||
|
snapshot: {
|
||||||
|
s0: snapshot.s0,
|
||||||
|
lastUpdatedAt: snapshot.lastUpdatedAt,
|
||||||
|
sampleCount: snapshot.sampleCount,
|
||||||
|
},
|
||||||
|
persona: user.persona,
|
||||||
|
intensity: user.reviewIntensity,
|
||||||
|
difficulty: D,
|
||||||
|
targetP,
|
||||||
|
reached,
|
||||||
|
iterationsToTarget: reached ? lastPoint.iteration : null,
|
||||||
|
daysToTarget: reached ? lastPoint.daysFromNow : null,
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async bySubject(userId: number) {
|
async bySubject(userId: number) {
|
||||||
const subjects = await this.prisma.subject.findMany({
|
const subjects = await this.prisma.subject.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export default function RootLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ const TagName = styled.span`
|
|||||||
const Spacer = styled.span`
|
const Spacer = styled.span`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
const ProblemTitle = styled.h3`
|
const ProblemTitle = styled.h3.attrs<{ 'data-selectable'?: string }>({
|
||||||
|
'data-selectable': 'true',
|
||||||
|
})`
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -249,13 +249,17 @@ const Meta = styled.div`
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProblemTitle = styled.h3`
|
const ProblemTitle = styled.h3.attrs<{ 'data-selectable'?: string }>({
|
||||||
|
'data-selectable': 'true',
|
||||||
|
})`
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.color.textMain};
|
color: ${theme.color.textMain};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Memo = styled.p`
|
const Memo = styled.p.attrs<{ 'data-selectable'?: string }>({
|
||||||
|
'data-selectable': 'true',
|
||||||
|
})`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.color.textSub};
|
color: ${theme.color.textSub};
|
||||||
padding: ${theme.space.sm};
|
padding: ${theme.space.sm};
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { theme } from '@/styles/theme';
|
import { theme } from '@/styles/theme';
|
||||||
import { api, type ForgetCurveResponse } from '@/lib/api';
|
import { api, type ForgetCurveResponse, type MasteryPathResponse } from '@/lib/api';
|
||||||
import AppShell from '@/components/layout/AppShell';
|
import AppShell from '@/components/layout/AppShell';
|
||||||
import { Badge, Button, Card, SectionTitle, Stack } from '@/components/ui/primitives';
|
import { Badge, Button, Card, SectionTitle, Stack } from '@/components/ui/primitives';
|
||||||
import ForgetCurveChart from '@/components/charts/ForgetCurveChart';
|
import ForgetCurveChart from '@/components/charts/ForgetCurveChart';
|
||||||
|
import MasteryPathChart from '@/components/charts/MasteryPathChart';
|
||||||
|
|
||||||
interface SubjectStats {
|
interface SubjectStats {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,6 +22,8 @@ interface SubjectStats {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'mastery' | 'forget';
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -33,6 +36,10 @@ function StatsBody() {
|
|||||||
const [subjects, setSubjects] = useState<SubjectStats[] | null>(null);
|
const [subjects, setSubjects] = useState<SubjectStats[] | null>(null);
|
||||||
const [subjectsError, setSubjectsError] = useState<string | null>(null);
|
const [subjectsError, setSubjectsError] = useState<string | null>(null);
|
||||||
const [selectedTag, setSelectedTag] = useState<number | null>(null);
|
const [selectedTag, setSelectedTag] = useState<number | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('mastery');
|
||||||
|
|
||||||
|
const [mastery, setMastery] = useState<MasteryPathResponse | null>(null);
|
||||||
|
const [masteryError, setMasteryError] = useState<string | null>(null);
|
||||||
const [curve, setCurve] = useState<ForgetCurveResponse | null>(null);
|
const [curve, setCurve] = useState<ForgetCurveResponse | null>(null);
|
||||||
const [curveError, setCurveError] = useState<string | null>(null);
|
const [curveError, setCurveError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -50,10 +57,34 @@ function StatsBody() {
|
|||||||
.find((t) => t.s0 !== null);
|
.find((t) => t.s0 !== null);
|
||||||
if (firstWithData) setSelectedTag(firstWithData.tagId);
|
if (firstWithData) setSelectedTag(firstWithData.tagId);
|
||||||
})
|
})
|
||||||
.catch(() => { if (!cancelled) setSubjectsError('통계를 불러오지 못했어'); });
|
.catch(() => {
|
||||||
return () => { cancelled = true; };
|
if (!cancelled) setSubjectsError('통계를 불러오지 못했어');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMastery = useCallback(() => {
|
||||||
|
if (selectedTag === null) return;
|
||||||
|
setMasteryError(null);
|
||||||
|
setMastery(null);
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.get<MasteryPathResponse>('/stats/mastery-path', {
|
||||||
|
params: { tagId: selectedTag, targetP: 0.9 },
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setMastery(r.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setMasteryError('마스터리 경로를 불러오지 못했어');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedTag]);
|
||||||
|
|
||||||
const loadCurve = useCallback(() => {
|
const loadCurve = useCallback(() => {
|
||||||
if (selectedTag === null) return;
|
if (selectedTag === null) return;
|
||||||
setCurveError(null);
|
setCurveError(null);
|
||||||
@@ -63,9 +94,15 @@ function StatsBody() {
|
|||||||
.get<ForgetCurveResponse>('/stats/forget-curve', {
|
.get<ForgetCurveResponse>('/stats/forget-curve', {
|
||||||
params: { tagId: selectedTag, days: 30, steps: 60 },
|
params: { tagId: selectedTag, days: 30, steps: 60 },
|
||||||
})
|
})
|
||||||
.then((r) => { if (!cancelled) setCurve(r.data); })
|
.then((r) => {
|
||||||
.catch(() => { if (!cancelled) setCurveError('망각 곡선을 불러오지 못했어'); });
|
if (!cancelled) setCurve(r.data);
|
||||||
return () => { cancelled = true; };
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCurveError('망각 곡선을 불러오지 못했어');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [selectedTag]);
|
}, [selectedTag]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,15 +111,18 @@ function StatsBody() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTag === null) return;
|
if (selectedTag === null) return;
|
||||||
|
if (viewMode === 'mastery') return loadMastery();
|
||||||
return loadCurve();
|
return loadCurve();
|
||||||
}, [selectedTag, loadCurve]);
|
}, [selectedTag, viewMode, loadMastery, loadCurve]);
|
||||||
|
|
||||||
if (subjectsError) {
|
if (subjectsError) {
|
||||||
return (
|
return (
|
||||||
<ErrorBlock>
|
<ErrorBlock>
|
||||||
<ErrorEmoji>⚠️</ErrorEmoji>
|
<ErrorEmoji>⚠️</ErrorEmoji>
|
||||||
<ErrorTitle>{subjectsError}</ErrorTitle>
|
<ErrorTitle>{subjectsError}</ErrorTitle>
|
||||||
<Button $size="md" onClick={loadSubjects}>다시 시도</Button>
|
<Button $size="md" onClick={loadSubjects}>
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
</ErrorBlock>
|
</ErrorBlock>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,48 +133,46 @@ function StatsBody() {
|
|||||||
<Wrap>
|
<Wrap>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>통계</Title>
|
<Title>통계</Title>
|
||||||
<Sub>페르소나 망각 곡선과 주제별 숙련도.</Sub>
|
<Sub>곧 내 것이 되는 영역과 주제별 숙련도.</Sub>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<SectionTitle>망각 곡선</SectionTitle>
|
<ChartHeader>
|
||||||
|
<ViewToggle>
|
||||||
|
<ToggleBtn
|
||||||
|
type="button"
|
||||||
|
$active={viewMode === 'mastery'}
|
||||||
|
onClick={() => setViewMode('mastery')}
|
||||||
|
>
|
||||||
|
🎯 마스터리 경로
|
||||||
|
</ToggleBtn>
|
||||||
|
<ToggleBtn
|
||||||
|
type="button"
|
||||||
|
$active={viewMode === 'forget'}
|
||||||
|
onClick={() => setViewMode('forget')}
|
||||||
|
>
|
||||||
|
📉 망각 곡선
|
||||||
|
</ToggleBtn>
|
||||||
|
</ViewToggle>
|
||||||
|
</ChartHeader>
|
||||||
|
|
||||||
{selectedTag === null ? (
|
{selectedTag === null ? (
|
||||||
<Empty>
|
<Empty>
|
||||||
태그에 연결된 학습 기록이 아직 없어. 학습 기록을 남기면 곡선이 그려져.
|
태그에 연결된 학습 기록이 아직 없어. 학습 기록을 남기면 여기에 경로가
|
||||||
|
그려져.
|
||||||
</Empty>
|
</Empty>
|
||||||
) : curveError ? (
|
) : viewMode === 'mastery' ? (
|
||||||
<ErrorBlock>
|
<MasteryView
|
||||||
<ErrorEmoji>⚠️</ErrorEmoji>
|
data={mastery}
|
||||||
<ErrorTitle>{curveError}</ErrorTitle>
|
error={masteryError}
|
||||||
<Button $size="md" onClick={loadCurve}>다시 시도</Button>
|
onRetry={loadMastery}
|
||||||
</ErrorBlock>
|
/>
|
||||||
) : curve === null ? (
|
) : (
|
||||||
<Loading>곡선 그리는 중...</Loading>
|
<ForgetView
|
||||||
) : (
|
data={curve}
|
||||||
<>
|
error={curveError}
|
||||||
<CurveHeader>
|
onRetry={loadCurve}
|
||||||
<CurveTitle>
|
|
||||||
<Dot $color={curve.tag.subject.color} />
|
|
||||||
<span>
|
|
||||||
{curve.tag.subject.name} · {curve.tag.name}
|
|
||||||
</span>
|
|
||||||
</CurveTitle>
|
|
||||||
<CurveMeta>
|
|
||||||
<Badge $variant="info">
|
|
||||||
S₀ {Math.round(curve.snapshot.s0 * 100)}%
|
|
||||||
</Badge>
|
|
||||||
<Badge>페르소나 {personaLabel(curve.persona)}</Badge>
|
|
||||||
<Badge>강도 {intensityLabel(curve.intensity)}</Badge>
|
|
||||||
<Badge>
|
|
||||||
난이도 {Math.round(curve.difficulty * 100)}%
|
|
||||||
</Badge>
|
|
||||||
</CurveMeta>
|
|
||||||
</CurveHeader>
|
|
||||||
<ForgetCurveChart
|
|
||||||
data={curve}
|
|
||||||
threshold={intensityThreshold(curve.intensity)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -171,9 +209,7 @@ function StatsBody() {
|
|||||||
<BarFill $value={t.s0 ?? 0} $color={s.color} />
|
<BarFill $value={t.s0 ?? 0} $color={s.color} />
|
||||||
</Bar>
|
</Bar>
|
||||||
<TagValue>
|
<TagValue>
|
||||||
{t.s0 === null
|
{t.s0 === null ? '—' : `${Math.round(t.s0 * 100)}%`}
|
||||||
? '—'
|
|
||||||
: `${Math.round(t.s0 * 100)}%`}
|
|
||||||
</TagValue>
|
</TagValue>
|
||||||
<SampleCount>n={t.sampleCount}</SampleCount>
|
<SampleCount>n={t.sampleCount}</SampleCount>
|
||||||
</TagRow>
|
</TagRow>
|
||||||
@@ -188,6 +224,116 @@ function StatsBody() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mastery view ──
|
||||||
|
function MasteryView({
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
data: MasteryPathResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorBlock>
|
||||||
|
<ErrorEmoji>⚠️</ErrorEmoji>
|
||||||
|
<ErrorTitle>{error}</ErrorTitle>
|
||||||
|
<Button $size="md" onClick={onRetry}>
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</ErrorBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return <Loading>마스터리 경로 계산 중...</Loading>;
|
||||||
|
|
||||||
|
const currentP = Math.round(data.points[0].p * 100);
|
||||||
|
const targetP = Math.round(data.targetP * 100);
|
||||||
|
const hopefulCopy = data.reached
|
||||||
|
? `앞으로 ${data.iterationsToTarget}회차 더 복습하면 약 ${Math.round(
|
||||||
|
data.daysToTarget ?? 0,
|
||||||
|
)}일 뒤 마스터 예상이야 ✨`
|
||||||
|
: `지금 페르소나 + 난이도로는 10회차 안에 목표 ${targetP}% 에 못 닿아. 페르소나를 재조정해봐.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MasteryHeader>
|
||||||
|
<TagLine>
|
||||||
|
<Dot $color={data.tag.subject.color} />
|
||||||
|
<span>
|
||||||
|
{data.tag.subject.name} · {data.tag.name}
|
||||||
|
</span>
|
||||||
|
</TagLine>
|
||||||
|
<CurveMeta>
|
||||||
|
<Badge>현재 P {currentP}%</Badge>
|
||||||
|
<Badge $variant="success">목표 P {targetP}%</Badge>
|
||||||
|
<Badge>페르소나 {personaLabel(data.persona)}</Badge>
|
||||||
|
<Badge>난이도 {Math.round(data.difficulty * 100)}%</Badge>
|
||||||
|
</CurveMeta>
|
||||||
|
</MasteryHeader>
|
||||||
|
<MasteryPathChart data={data} />
|
||||||
|
<HopefulLine $reached={data.reached}>{hopefulCopy}</HopefulLine>
|
||||||
|
<IterationTable>
|
||||||
|
{data.points.map((p) => (
|
||||||
|
<IterRow key={p.iteration}>
|
||||||
|
<IterN>{p.iteration === 0 ? '지금' : `${p.iteration}회차`}</IterN>
|
||||||
|
<IterWhen>
|
||||||
|
{p.iteration === 0
|
||||||
|
? '현재'
|
||||||
|
: `+${Math.round(p.daysFromNow)}일 뒤`}
|
||||||
|
</IterWhen>
|
||||||
|
<IterP $hit={p.p >= data.targetP}>P {Math.round(p.p * 100)}%</IterP>
|
||||||
|
</IterRow>
|
||||||
|
))}
|
||||||
|
</IterationTable>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forget view (legacy) ──
|
||||||
|
function ForgetView({
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
data: ForgetCurveResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorBlock>
|
||||||
|
<ErrorEmoji>⚠️</ErrorEmoji>
|
||||||
|
<ErrorTitle>{error}</ErrorTitle>
|
||||||
|
<Button $size="md" onClick={onRetry}>
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</ErrorBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return <Loading>곡선 그리는 중...</Loading>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CurveHeader>
|
||||||
|
<CurveTitle>
|
||||||
|
<Dot $color={data.tag.subject.color} />
|
||||||
|
<span>
|
||||||
|
{data.tag.subject.name} · {data.tag.name}
|
||||||
|
</span>
|
||||||
|
</CurveTitle>
|
||||||
|
<CurveMeta>
|
||||||
|
<Badge $variant="info">S₀ {Math.round(data.snapshot.s0 * 100)}%</Badge>
|
||||||
|
<Badge>페르소나 {personaLabel(data.persona)}</Badge>
|
||||||
|
<Badge>강도 {intensityLabel(data.intensity)}</Badge>
|
||||||
|
<Badge>난이도 {Math.round(data.difficulty * 100)}%</Badge>
|
||||||
|
</CurveMeta>
|
||||||
|
</CurveHeader>
|
||||||
|
<ForgetCurveChart data={data} threshold={intensityThreshold(data.intensity)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function personaLabel(p: string): string {
|
function personaLabel(p: string): string {
|
||||||
return p === 'senior'
|
return p === 'senior'
|
||||||
? '상위권'
|
? '상위권'
|
||||||
@@ -204,6 +350,7 @@ function intensityThreshold(i: string): number {
|
|||||||
return i === 'strict' ? 0.7 : i === 'relaxed' ? 0.35 : 0.5;
|
return i === 'strict' ? 0.7 : i === 'relaxed' ? 0.35 : 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── styled ──
|
||||||
const Wrap = styled.div`
|
const Wrap = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -220,6 +367,100 @@ const Sub = styled.p`
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const ChartHeader = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: ${theme.space.md};
|
||||||
|
`;
|
||||||
|
const ViewToggle = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
background: ${theme.color.surface2};
|
||||||
|
border: 1px solid ${theme.color.border};
|
||||||
|
border-radius: ${theme.radius.pill};
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
`;
|
||||||
|
const ToggleBtn = styled.button<{ $active: boolean }>`
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: ${theme.radius.pill};
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${({ $active }) => ($active ? 'white' : theme.color.textSub)};
|
||||||
|
background: ${({ $active }) => ($active ? theme.color.accent : 'transparent')};
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${({ $active }) => ($active ? 'white' : theme.color.textMain)};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MasteryHeader = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: ${theme.space.md};
|
||||||
|
margin-bottom: ${theme.space.md};
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
const TagLine = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${theme.space.sm};
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HopefulLine = styled.p<{ $reached: boolean }>`
|
||||||
|
margin-top: ${theme.space.md};
|
||||||
|
padding: ${theme.space.md};
|
||||||
|
background: ${({ $reached }) =>
|
||||||
|
$reached ? 'rgba(34, 197, 94, 0.12)' : 'rgba(245, 158, 11, 0.12)'};
|
||||||
|
border: 1px solid
|
||||||
|
${({ $reached }) =>
|
||||||
|
$reached ? 'rgba(34, 197, 94, 0.35)' : 'rgba(245, 158, 11, 0.35)'};
|
||||||
|
color: ${({ $reached }) =>
|
||||||
|
$reached ? theme.color.success : theme.color.warning};
|
||||||
|
border-radius: ${theme.radius.md};
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const IterationTable = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: ${theme.space.sm};
|
||||||
|
margin-top: ${theme.space.md};
|
||||||
|
`;
|
||||||
|
const IterRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: ${theme.space.sm};
|
||||||
|
background: ${theme.color.surface2};
|
||||||
|
border: 1px solid ${theme.color.border};
|
||||||
|
border-radius: ${theme.radius.sm};
|
||||||
|
`;
|
||||||
|
const IterN = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${theme.color.textMute};
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
`;
|
||||||
|
const IterWhen = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.color.textSub};
|
||||||
|
font-family: ${theme.font.mono};
|
||||||
|
`;
|
||||||
|
const IterP = styled.span<{ $hit: boolean }>`
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${({ $hit }) => ($hit ? theme.color.success : theme.color.textMain)};
|
||||||
|
`;
|
||||||
|
|
||||||
const CurveHeader = styled.div`
|
const CurveHeader = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -192,11 +192,15 @@ const Spacer = styled.span`
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
const ResultBadge = styled(Badge)``;
|
const ResultBadge = styled(Badge)``;
|
||||||
const LogTitle = styled.h3`
|
const LogTitle = styled.h3.attrs<{ 'data-selectable'?: string }>({
|
||||||
|
'data-selectable': 'true',
|
||||||
|
})`
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
`;
|
`;
|
||||||
const Memo = styled.p`
|
const Memo = styled.p.attrs<{ 'data-selectable'?: string }>({
|
||||||
|
'data-selectable': 'true',
|
||||||
|
})`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.color.textSub};
|
color: ${theme.color.textSub};
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
98
frontend/src/components/charts/MasteryPathChart.tsx
Normal file
98
frontend/src/components/charts/MasteryPathChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
import type { MasteryPathResponse } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: MasteryPathResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MasteryPathChart({ data }: Props) {
|
||||||
|
const points = data.points.map((p) => ({
|
||||||
|
iter: p.iteration,
|
||||||
|
p: Math.round(p.p * 100),
|
||||||
|
days: Number(p.daysFromNow.toFixed(1)),
|
||||||
|
s0: Math.round(p.s0 * 100),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: 320 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<LineChart data={points} margin={{ top: 12, right: 16, left: 0, bottom: 16 }}>
|
||||||
|
<CartesianGrid stroke={theme.color.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="iter"
|
||||||
|
stroke={theme.color.textMute}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
label={{
|
||||||
|
value: '복습 회차',
|
||||||
|
position: 'insideBottom',
|
||||||
|
offset: -4,
|
||||||
|
fill: theme.color.textMute,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke={theme.color.textMute}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
domain={[0, 100]}
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: theme.color.surface,
|
||||||
|
border: `1px solid ${theme.color.border}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelFormatter={(v) => `${v}회차`}
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === 'p') return [`${value}%`, '정답 확률 P'];
|
||||||
|
if (name === 's0') return [`${value}%`, '기억 강도 S₀'];
|
||||||
|
if (name === 'days') return [`${value}일 뒤`, '예상'];
|
||||||
|
return [value, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
y={Math.round(data.targetP * 100)}
|
||||||
|
stroke={theme.color.success}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
label={{
|
||||||
|
value: `목표 ${Math.round(data.targetP * 100)}%`,
|
||||||
|
fill: theme.color.success,
|
||||||
|
fontSize: 10,
|
||||||
|
position: 'insideTopRight',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="p"
|
||||||
|
stroke={theme.color.accent2}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: theme.color.accent, r: 4 }}
|
||||||
|
activeDot={{ r: 6, fill: theme.color.accentHover }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="s0"
|
||||||
|
stroke={theme.color.accent}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -257,3 +257,19 @@ export const HelpText = styled.p`
|
|||||||
color: ${theme.color.textMute};
|
color: ${theme.color.textMute};
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 user-select 금지의 명시적 예외 — 문제 본문/메모 등 사용자가 복사해서
|
||||||
|
* 다른 곳에 붙여넣고 싶어할 수 있는 텍스트에 사용한다.
|
||||||
|
*
|
||||||
|
* 사용: <Selectable as="span">복사 가능한 텍스트</Selectable>
|
||||||
|
* 또는 기존 styled 요소에 data-selectable 속성을 부여해도 동일 효과.
|
||||||
|
*/
|
||||||
|
export const Selectable = styled.span.attrs<{ 'data-selectable'?: string }>({
|
||||||
|
'data-selectable': 'true',
|
||||||
|
})`
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
`;
|
||||||
|
|||||||
@@ -118,10 +118,29 @@ export interface DashboardSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ForgetCurveResponse {
|
export interface ForgetCurveResponse {
|
||||||
tag: { id: number; name: string; subject: Subject };
|
tag: { id: number; name: string; subject: { id: number; name: string; color: string } };
|
||||||
snapshot: { s0: number; lastUpdatedAt: string; sampleCount: number };
|
snapshot: { s0: number; lastUpdatedAt: string; sampleCount: number };
|
||||||
persona: Persona;
|
persona: Persona;
|
||||||
intensity: ReviewIntensity;
|
intensity: ReviewIntensity;
|
||||||
difficulty: number;
|
difficulty: number;
|
||||||
points: Array<{ t: number; s: number; p: number }>;
|
points: Array<{ t: number; s: number; p: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MasteryPathResponse {
|
||||||
|
tag: { id: number; name: string; subject: { id: number; name: string; color: string } };
|
||||||
|
snapshot: { s0: number; lastUpdatedAt: string; sampleCount: number };
|
||||||
|
persona: Persona;
|
||||||
|
intensity: ReviewIntensity;
|
||||||
|
difficulty: number;
|
||||||
|
targetP: number;
|
||||||
|
reached: boolean;
|
||||||
|
iterationsToTarget: number | null;
|
||||||
|
daysToTarget: number | null;
|
||||||
|
points: Array<{
|
||||||
|
iteration: number;
|
||||||
|
scheduledAt: string;
|
||||||
|
s0: number;
|
||||||
|
p: number;
|
||||||
|
daysFromNow: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ const GlobalStyle = createGlobalStyle`
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
/* 드래그/선택/컨텍스트 메뉴 기본 금지 — 예외는 아래 selector 로 허용 */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 예외: 입력 필드와 명시적으로 selectable 로 지정된 요소만 선택 가능 */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
[contenteditable='true'],
|
||||||
|
[data-selectable='true'],
|
||||||
|
[data-selectable='true'] * {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이미지 드래그도 기본 금지 */
|
||||||
|
img {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle radial accent glows behind everything */
|
/* Subtle radial accent glows behind everything */
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const theme = {
|
|||||||
|
|
||||||
font: {
|
font: {
|
||||||
sans:
|
sans:
|
||||||
"'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
"'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
||||||
mono:
|
mono:
|
||||||
"'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
"'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user