- stats.service.ts: ceilingP = σ(k·(1-D)) 계산. 이 문제의 이론적 P 상한.
effectiveTarget = min(targetP, ceilingP - 0.02) 로 좁혀서 실제 도달 가능한
현실적 목표를 쓴다. 응답에 ceilingP/effectiveTarget 추가.
- api.ts: MasteryPathResponse 에 두 필드 추가.
- stats/page.tsx: capped 인 경우 '이 난이도의 한계는 X% 야. N회차 K일 뒤 그
한계 근처에 도달해' 카피 + 목표 배지에 '(한계)' 표시.
- MasteryPathChart: 기준선을 effectiveTarget 으로.
트랙 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 부여 (검색용 복사 허용).
components/ui/Modal.tsx (new)
- ConfirmDialog: overlay + card backed by theme tokens, role=dialog +
aria-modal, backdrop click = cancel, stopPropagation on card.
app/subjects/[id]/page.tsx
- subjectId = Number.isFinite(raw) && raw > 0 ? raw : null
- Early return rendering InvalidWrap when subjectId is null; no API call
with NaN, no infinite 'loading' deadend for /subjects/abc.
- Tag delete uses ConfirmDialog via confirmDelete state (no confirm()).
app/subjects/page.tsx
- Native confirm()/alert() eliminated. Delete flow uses ConfirmDialog
with danger tone + ToastBar for success/failure feedback.
app/review/history/page.tsx
app/study/history/page.tsx
app/stats/page.tsx
- Fetches wrapped in useCallback, .catch() sets an error state, render
shows ErrorBlock with a '다시 시도' retry button.
- stats/page.tsx gets both subjectsError (full-page) and curveError
(inline in the curve card) plus independent retries.
- Cancelled-flag pattern (let cancelled = false) prevents stale
setState after unmount across all four pages.
backend/src/reviews/reviews.service.ts
- submit(): entire write path (status flip + snapshot upsert + next
schedule create) now runs inside prisma.$transaction(async tx => ...)
- updateMany where { id, status: 'pending' } + count===0 → ConflictException
acts as the atomicity gate; concurrent POSTs can no longer double-write.
- Next iteration is re-fetched inside the tx via findFirst done+desc, so
racing requests can't both compute iteration=N+1 off a stale read.
- Single const now = new Date() shared across snapshot and schedule call
(also addresses QA-2 minor m2).
- skip() gets the same updateMany guard and throws ConflictException when
the row has already been processed.
frontend/src/app/review/page.tsx
- Global 'submitting: boolean' replaced with 'pending: Set<number>' +
isPending(id) helper.
- submit()/skip() guard re-entry via if (isPending(id)) return and use
functional Set updates in finally.
- All four Button elements disabled={isPending(q.id)} — only the card in
flight is locked, not the whole page.
Closes the double-submit race that corrupted sampleCount / iteration.