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:
reloop
2026-04-12 01:08:49 +09:00
parent e0ac7a4c42
commit 9939f299d8
12 changed files with 603 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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",
}, },