feat: 프로필 알고리즘 설명 + 시험목표 삭제 + PDF 파싱 버그 수정 + README 재작성

- 프로필 학습설정에 현재 페르소나/강도 기반 복습 알고리즘 설명 카드 추가
- 시험 목표/집중 단원/BOJ 핸들 섹션 제거
- PDF 문제 분리: codex Vision OCR → pdftotext 텍스트 파싱으로 교체 (운영서버 호환)
- README.md 전면 재작성 (최신 기능 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-17 09:14:50 +09:00
parent f87563270e
commit 8ede8efbec
3 changed files with 239 additions and 295 deletions

300
README.md
View File

@@ -1,248 +1,196 @@
# ReLoop v2
**잊기 전에 다시 만나는 학습법.** 페르소나 기반 망각 곡선으로 개인화된 복습 간격을 자동으로 계산하는 적응형 학습 플랫폼.
수능, 자격증, 어학, 취미 학습 — 어떤 공부든 ReLoop이 기억을 관리합니다.
**잊기 전에 다시 만나는 학습법.** 에빙하우스 망각곡선 + SM-2 알고리즘으로 개인화된 복습 간격을 자동 계산하는 수학 학습 플랫폼.
## 핵심 기능
| 기능 | 설명 |
|------|------|
| **적응형 복습 스케줄링** | 페르소나 + 문제 난이도에 따라 "정답률이 기준 아래로 떨어지는 시점"을 자동 계산해 복습 큐에 넣습니다 |
| **KICE 기출 문제집** | 2025/2026 수능 수학 4단원(공통/확률과 통계/미적분/기하) 92문항을 PDF에서 자동 크롭한 이미지로 제공 |
| **모의고사 모드** | 실제 시험처럼 타이머 + OMR 답안 선택 + 자동 채점 + 학습 기록 일괄 생성 |
| **Solved.ac PS 연동** | Solved.ac 계정에서 최신 백준 풀이 이력을 동기화해 복습 큐에 추가 |
| **통계 대시보** | 학습 히트맵, 단원별·PS 정답률, 숙련도 성장 곡선을 시각화 |
| **PWA 지원** | 모바일 홈 화면에 앱으로 설치 가능 |
| **적응형 복습 스케줄링** | 4가지 학습자 페르소나 + 3단계 자기평가로 최적의 복습 시점을 자동 계산 |
| **복습 캘린더** | 월간 달력에서 날짜별 복습 현황 확인, 밀린 복습 자동 이월 |
| **KICE 기출 문제집** | 수능/모의고사 수학 92문항, PDF 자동 크롭 이미지 제공 |
| **PDF 문제 분리** | 모의고사 PDF 업로드 → 텍스트 파싱으로 문제 단위 분리 → 태그 배정 → 복습 큐 등록 |
| **모의고사 모** | 타이머 + OMR 답안 선택 + 자동 채점 + 학습 기록 일괄 생성 |
| **3단계 자기평가** | 어려움/보통/쉬움 → SM-2 기반 간격 배정 (hard=1일, easy=점점 벌어짐) |
| **필기모드** | Canvas 오버레이, 펜 3색, 문제별 localStorage 저장 |
| **B2B QR 등록** | 종이 문제집 QR 코드 → 앱 ebook 자동 등록 |
| **학교/학원 관리** | 학급 생성, 과제 배정, 학생별 리포트 |
| **통계 대시보드** | 단원별 정답률, 숙련도 성장 곡선, 주간 트렌드 |
## 알고리즘
ReLoop은 두 가지 복습 경로를 제공합니다.
### 1. 페르소나 망각곡선 (기본)
```
S(t) = S₀ × exp(-λ × t) 실력 감쇄
P = sigmoid(k × (S(t) - D)) 정답 확률
```
정답 확률 P가 기준(P_threshold) 아래로 떨어지는 시점을 다음 복습일로 설정합니다.
| 페르소나 | λ (망각속도) | 간격 배율 | 설명 |
|---------|-------------|----------|------|
| 상위권 (senior) | 0.1 | ×1.3 | 느린 망각, 넓은 복습 간격 |
| 중위권 (mid) | 0.2 | ×1.0 | 표준 설정 |
| 하위권 (junior) | 0.4 | ×0.8 | 빠른 망각, 조밀한 복습 |
| 벼락치기형 (crammer) | 0.6 | ×0.6 | 초단기 집중 복습 |
| 학습 강도 | P_threshold | 의미 |
|----------|-------------|------|
| 엄격 | 0.70 | 아직 70%일 때 미리 복습 |
| 균형 | 0.50 | 반반일 때 복습 (기본) |
| 여유 | 0.35 | 많이 까먹은 후 복습 |
### 2. SM-2 하이브리드 (자기평가 시)
복습 시 어려움/보통/쉬움을 선택하면 SM-2 알고리즘이 적용됩니다.
- **어려움** → 1일 후 재복습, EF -0.3
- **보통** → 현재 간격 유지
- **쉬움** → 간격 확대, EF +0.15
- 간격에 페르소나 배율이 곱해짐 (최대 60일 cap)
## 기술 스택
| 영역 | 기술 |
|------|------|
| Backend | NestJS 10 + Prisma 5 + MariaDB |
| Frontend | Next.js 14 (App Router) + React 18 + styled-components 6 |
| Backend | NestJS 10, Prisma 5, MariaDB |
| Frontend | Next.js 14 (App Router), React 18, styled-components 6 |
| 인증 | JWT (passport-jwt) |
| 보안 | helmet, @nestjs/throttler (60req/min), CORS whitelist |
| 차트 | Recharts |
| 아이콘 | Phosphor React |
| 폰트 | Pretendard + Inter + JetBrains Mono |
| 배포 | PM2 on Debian VM, nginx reverse proxy |
| PDF 처리 | poppler-utils (pdftotext, pdftoppm, pdfinfo) |
| OCR | Codex CLI Vision (codex exec -i) |
| PDF 파싱 | poppler-utils (pdftotext, pdftoppm, pdfinfo) |
| 배포 | PM2, nginx reverse proxy |
## 프로젝트 구조
```
reloop-v2/
├── backend/ # NestJS API 서버
── src/
├── auth/ # JWT 인증 (login, register, me)
├── dashboard/ # 대시보드 요약 API
├── forget/ # 페르소나 망각 곡선 엔진
├── me/ # 프로필, 아바타 업로드
├── problem-sets/ # 문제집 CRUD + 파싱 모듈
│ └── parsing/ # PDF 파싱 엔진 (범용, 전략 패턴)
│ │ ├── strategies/ # kice.ts, math.ts
│ └── ocr-fallback/ # Codex Vision OCR
├── reviews/ # 복습 큐 관
├── stats/ # 통계 API
├── study-logs/ # 학습 기록 + 모의고사 일괄 제출
├── subjects/ # 과목 관리
── tags/ # 태그 관리
├── prisma/
│ │ ├── schema.prisma # DB 스키마
│ ├── seed.ts # 데모 데이터 시드
── migrations/ # 마이그레이션 히스토리
├── scripts/
│ │ ├── kice-import.ts # KICE PDF 임포트 CLI
│ │ ── ocr-fix.ts # OCR 보정 CLI
└── uploads/ # 문제 이미지, 아바타 (git 제외)
├── frontend/ # Next.js 프론트엔드
├── src/
├── app/ # App Router 페이지들
│ ├── dashboard/ # 대시보드 (채움/빈 상태)
│ ├── exams/ # 문제집 카탈로그
│ ├── study/
│ │── exam/[id]/ # 모의고사 시험 화면
│ │ └── history/ # 학습 기록 히스토리
│ ├── review/ # 복습 세션
│ ├── solve/[id]/ # 개별 문제 풀기
── study-logs/[id]/ # 학습 기록 상세
│ ├── stats/ # 통계
│ │ │ ├── subjects/ # 과목 및 태그 관리
│ │ ├── profile/ # 프로필 + 설정 통합
│ │ │ ├── pricing/ # 플랜 (공개/로그인 분기)
│ │ │ ├── onboarding/ # 신규 사용자 온보딩
│ │ │ ├── login/ + register/ # 인증
│ │ │ ├── privacy/ terms/ contact/ # 법적 페이지
│ │ │ └── page.tsx # 랜딩 페이지
│ │ ├── components/
│ │ │ ├── ui/ # Button, Card, Icon, Toast, Select, DatePicker, ...
│ │ │ ├── layout/ # AppShell, SideNav, BottomNav
│ │ │ ├── auth/ # AuthCard (로그인/회원가입 통합)
│ │ │ ├── dashboard/ # EmptyDashboard
│ │ │ └── legal/ # LegalLayout
│ │ ├── lib/ # api.ts, auth.ts, constants.ts, exam-date.ts
│ │ └── styles/ # theme.ts (디자인 토큰, 애니메이션)
│ └── public/ # 정적 파일, PWA manifest, 아이콘
├── data/kice/ # KICE 원본 PDF (git 제외, 저작권)
│ ├── 2025/{국어,수학,영어,...}/
│ └── 2026/{국어,수학,영어,...}/
├── docs/ # 상세 매뉴얼
│ ├── algorithm.md # 망각 곡선 알고리즘 상세
│ ├── architecture.md # 시스템 아키텍처
│ ├── user-guide.md # 사용자 가이드
│ ├── admin-guide.md # 운영/배포 가이드
│ └── pdf-pipeline.md # PDF 파싱 파이프라인
└── Plans.md # 개발 계획 + 진행 상황
├── backend/
── src/
├── auth/ # JWT 인증
├── dashboard/ # 대시보드 요약
├── forget/ # 망각곡선 + SM-2 엔진
├── me/ # 프로필 (페르소나 변경 시 스케줄 재계산)
├── problem-sets/ # 문제집 + PDF 파싱
│ └── parsing/ # pdftotext 기반 파서 + OCR fallback
│ ├── reviews/ # 복습 큐 (자동 이월 포함)
├── stats/ # 통계
├── study-logs/ # 학습 기록 + PDF 문제 분
├── subjects/ # 과목
├── tags/ # 태그
├── organizations/ # 학교/학원
── classes/ # 학급
└── assignments/ # 과제
├── frontend/
└── src/
── app/
│ ├── dashboard/ # 대시보드 (캘린더, 숙련도)
│ ├── review/ # 복습 세션 (키보드 단축키)
── exams/ # 문제집 카탈로그
│ ├── study/
├── exam/ # 모의고사 화면
│ │ ├── import-pdf/# PDF 문제 분리
└── history/ # 학습 기록
│ ├── stats/ # 통계
│ ├── subjects/ # 과목/태그 관리
│ ├── profile/ # 프로필 + 학습설정 + 알고리즘 설명
── school/ # 학교 관리
├── components/
│ ├── ui/ # 공통 UI 컴포넌트
│ ├── layout/ # AppShell, SideNav, BottomNav
── charts/ # 망각곡선, 숙련도 차트
└── lib/ # API 클라이언트, 상수, 인증
└── prisma/
└── schema.prisma # DB 스키마
```
## 알고리즘 요약
> 상세 내용: [docs/algorithm.md](docs/algorithm.md)
ReLoop은 **FSRS 대신 자체 페르소나 기반 망각 곡선**을 사용합니다.
### 핵심 수식
```
실력 감쇄: S(t) = S₀ × exp(-λ × t)
정답 확률: P = sigmoid(k × (S(t) - D))
```
- **S₀** (0~1): 마지막 학습 시점의 실력. 맞으면 올라가고 틀리면 떨어짐
- **λ (lambda)**: 페르소나별 망각 속도. senior(0.1)은 느리게, crammer(0.6)은 빠르게 잊음
- **D**: 문제 난이도 (0~1)
- **k**: sigmoid 기울기 (기본 4.0)
### 복습 시점 결정
"정답 확률 P가 사용자의 기준(P_threshold) 아래로 떨어지는 시점"을 다음 복습일로 잡습니다.
| 복습 민감도 | P_threshold | 의미 |
|------------|-------------|------|
| 엄격 (strict) | 0.7 | 70% 이하로 떨어지면 복습 |
| 균형 (moderate) | 0.5 | 50% 이하 |
| 여유 (relaxed) | 0.35 | 35% 이하 |
### 페르소나
| 페르소나 | λ (망각속도) | 복습 주기 경향 |
|---------|-------------|---------------|
| Senior | 0.1 | 7~14일 (느린 망각) |
| Mid | 0.2 | 2~5일 |
| Junior | 0.4 | 1~2일 (빠른 망각) |
| Crammer | 0.6 | 매일~격일 |
### S₀ 업데이트 규칙
| 결과 | 새 S₀ |
|------|-------|
| 정답 | S₀ × 0.5 + 0.6 |
| 부분 | S₀ × 0.7 + 0.3 |
| 오답 | S₀ × 0.5 |
## 빠른 시작
### 필수 조건
- Node.js 20+
- pnpm 9+
- Node.js 20+, pnpm 9+
- MariaDB/MySQL 8+
- poppler-utils (`pdftotext`, `pdftoppm`, `pdfinfo`)
- poppler-utils (`apt install poppler-utils`)
### 설치 및 실행
### 설치
```bash
# 1. 클론
git clone ssh://git@your-gitea:2222/hanarang/reloop-v2.git
cd reloop-v2
# 2. 백엔드
# 백엔드
cd backend
pnpm install
cp .env.example .env # DATABASE_URL, JWT_SECRET 편집
cp .env.example .env # DATABASE_URL, JWT_SECRET 편집
pnpm prisma migrate dev
pnpm prisma:seed # 데모 계정 생성
pnpm dev # http://localhost:3001
pnpm prisma:seed
pnpm dev # :3001
# 3. 프론트엔드 (새 터미널)
cd frontend
# 프론트엔드
cd ../frontend
pnpm install
cp .env.example .env.local # NEXT_PUBLIC_API_URL 편집
pnpm dev # http://localhost:3000
# 4. (선택) KICE 기출 임포트
# data/kice/ 에 PDF 배치 후:
cd backend
pnpm cli:kice-import --year=2026
cp .env.example .env.local # NEXT_PUBLIC_API_URL 편집
pnpm dev # :3000
```
### 데모 계정
### 환경 변수
| 이메일 | 비밀번호 |
|--------|---------|
| demo@reloop.local | demo1234 |
**Backend** (`backend/.env`)
## 환경 변수
| 변수 | 필수 | 설명 |
|------|------|------|
| `DATABASE_URL` | O | `mysql://user:pass@host:3306/reloop_v2` |
| `JWT_SECRET` | O | 32자+ 랜덤 문자열 |
| `JWT_EXPIRES_IN` | - | 기본 `7d` |
| `CORS_ORIGINS` | - | 허용 도메인 (쉼표 구분) |
| `PORT` | - | 기본 `3001` |
### Backend (`backend/.env`)
**Frontend** (`frontend/.env.local`)
| 변수 | 필수 | 기본값 | 설명 |
|------|------|--------|------|
| `DATABASE_URL` | O | — | `mysql://user:pass@host:3306/reloop_v2` |
| `JWT_SECRET` | O | — | 32자 이상 랜덤 문자열 |
| `JWT_EXPIRES_IN` | — | `7d` | 토큰 만료 기간 |
| `CORS_ORIGINS` | — | `http://localhost:3000` | 쉼표 구분 허용 도메인 |
| `PORT` | — | `3001` | API 서버 포트 |
### Frontend (`frontend/.env.local`)
| 변수 | 필수 | 기본값 |
|------|------|--------|
| `NEXT_PUBLIC_API_URL` | — | `http://localhost:3001/api` |
| 변수 | 설명 |
|------|------|
| `NEXT_PUBLIC_API_URL` | 기본 `http://localhost:3001/api` |
## 배포
> 상세 내용: [docs/admin-guide.md](docs/admin-guide.md)
```bash
# 서버에서
cd /path/to/reloop-v2
# 백엔드
cd backend && pnpm install && pnpm prisma:deploy && pnpm build
cd backend && rm -f *.tsbuildinfo && pnpm build
pm2 restart reloop-api
# 프론트엔드
cd frontend && pnpm install && pnpm build
cd ../frontend && pnpm build
cp -r .next/static .next/standalone/.next/
cp -r public .next/standalone/
pm2 restart reloop-web
```
## API 요약
> 상세 내용: [docs/architecture.md](docs/architecture.md)
## API 엔드포인트
| Method | Endpoint | 설명 |
|--------|----------|------|
| POST | `/api/auth/register` | 회원가입 |
| POST | `/api/auth/login` | 로그인 (JWT 발급) |
| POST | `/api/auth/login` | 로그인 |
| GET | `/api/auth/me` | 내 정보 |
| PATCH | `/api/me/profile` | 프로필 수정 |
| POST | `/api/me/avatar` | 아바타 업로드 |
| PATCH | `/api/me/profile` | 프로필 수정 (페르소나 변경 시 스케줄 재계산) |
| GET | `/api/dashboard/summary` | 대시보드 요약 |
| GET | `/api/problem-sets` | 문제집 목록 |
| GET | `/api/problem-sets/:id` | 문제집 상세 (문항 + 지문) |
| GET | `/api/reviews/queue` | 복습 큐 (밀린 복습 자동 이월) |
| POST | `/api/reviews/:id/submit` | 복습 결과 제출 |
| GET | `/api/reviews/calendar` | 월간 복습 캘린더 |
| GET | `/api/reviews/day` | 일별 복습 상세 |
| GET | `/api/study-logs` | 학습 기록 목록 |
| POST | `/api/study-logs` | 학습 기록 생성 |
| GET | `/api/study-logs/:id` | 학습 기록 상세 |
| PATCH | `/api/study-logs/:id` | 학습 기록 수정 |
| POST | `/api/study-logs/from-problem-set` | 모의고사 일괄 제출 |
| GET | `/api/reviews/queue` | 오늘의 복습 큐 |
| POST | `/api/reviews/:id/submit` | 복습 결과 제출 |
| POST | `/api/study-logs/parse-pdf` | PDF 문제 분리 (OCR) |
| POST | `/api/study-logs/import-from-pdf` | 분리된 문제 등록 |
| GET | `/api/problem-sets` | 문제집 목록 |
| GET | `/api/subjects` | 과목 목록 |
| GET | `/api/stats/subjects` | 과목별 통계 |
| GET | `/api/stats/mastery-path` | 숙련도 성장 곡선 |
## 라이선스

View File

@@ -289,7 +289,9 @@ export class StudyLogsService {
}
async parsePdfToProblems(pdfPath: string, title: string) {
const { ocrProblemPage } = await import('../problem-sets/parsing/ocr-fallback/index');
const { extractText } = await import('../problem-sets/parsing/extract-text');
const { parseProblemPaper } = await import('../problem-sets/parsing/parse-problem-paper');
const { kiceMathStrategy } = await import('../problem-sets/parsing/strategies/math');
const { execFileSync } = await import('child_process');
let pageCount = 1;
@@ -298,32 +300,30 @@ export class StudyLogsService {
const match = output.match(/^Pages:\s+(\d+)/m);
if (match) pageCount = Number(match[1]);
} catch {
// pdfinfo 미설치 시 1페이지로 시도
// pdfinfo 미설치 시 fallback
}
const allProblems: Array<{ number: number; bodyText: string; choices: Record<string, string> }> = [];
for (let page = 1; page <= pageCount; page++) {
try {
const problems = await ocrProblemPage(pdfPath, page);
allProblems.push(...problems);
} catch {
// 페이지 파싱 실패 시 건너뜀
}
// pdftotext 로 텍스트 추출 → 문제 파싱 (codex CLI 불필요)
let rawText = '';
try {
rawText = extractText(pdfPath, 'raw');
} catch {
throw new BadRequestException('PDF에서 텍스트를 추출할 수 없어. 이미지 전용 PDF는 아직 지원하지 않아.');
}
// 중복 번호 제거 (첫 번째 유지)
const seen = new Set<number>();
const unique = allProblems.filter((p) => {
if (seen.has(p.number)) return false;
seen.add(p.number);
return true;
});
const parsed = parseProblemPaper(rawText, kiceMathStrategy);
const problems = parsed.problems.map((p) => ({
number: p.number,
bodyText: p.bodyText,
choices: p.choices,
}));
return {
title,
problems: unique.sort((a, b) => a.number - b.number),
problems,
totalPages: pageCount,
warnings: parsed.warnings,
};
}

View File

@@ -11,9 +11,7 @@ import { Button, Card } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import {
api,
MATH_UNIT_LABEL,
resolveAssetUrl,
type MathUnit,
type MeUser,
type Persona,
type ReviewIntensity,
@@ -50,10 +48,8 @@ type ProfilePatch = Partial<
> & { onboarded?: boolean }
>;
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
const NOTIFICATION_STORAGE_KEY = 'reloop:profile-notifications';
const FETCH_LIMIT = 200;
const MATH_UNIT_OPTIONS = Object.entries(MATH_UNIT_LABEL) as Array<[MathUnit, string]>;
const DEFAULT_NOTIFICATIONS: NotificationPrefs = {
pushDailyReview: true,
weeklyReport: true,
@@ -109,8 +105,6 @@ function ProfileBody() {
const [isEditingNickname, setIsEditingNickname] = useState(false);
const [currentGradeDraft, setCurrentGradeDraft] = useState(5);
const [targetGradeDraft, setTargetGradeDraft] = useState(3);
const [targetExamYearDraft, setTargetExamYearDraft] = useState(new Date().getFullYear() + 1);
const [bojHandleDraft, setBojHandleDraft] = useState('');
const [notifications, setNotifications] =
useState<NotificationPrefs>(DEFAULT_NOTIFICATIONS);
const [studyLogs, setStudyLogs] = useState<StudyLog[]>([]);
@@ -196,8 +190,8 @@ function ProfileBody() {
setNicknameDraft(nextUser.nickname);
setCurrentGradeDraft(nextUser.currentGrade ?? 5);
setTargetGradeDraft(nextUser.targetGrade ?? 3);
setTargetExamYearDraft(nextUser.targetExamYear ?? new Date().getFullYear() + 1);
setBojHandleDraft(nextUser.bojHandle ?? '');
setAvatarPreviewUrl(null);
setStudyLogs(logs);
} finally {
@@ -221,16 +215,13 @@ function ProfileBody() {
setNicknameDraft(nextUser.nickname);
setCurrentGradeDraft(nextUser.currentGrade ?? currentGradeRef.current);
setTargetGradeDraft(nextUser.targetGrade ?? targetGradeRef.current);
setTargetExamYearDraft(nextUser.targetExamYear ?? targetExamYearDraft);
setBojHandleDraft(nextUser.bojHandle ?? '');
showToast({ message: successMessage, variant: 'success' });
} catch {
const currentUser = userRef.current;
setNicknameDraft(currentUser?.nickname ?? '');
setCurrentGradeDraft(currentUser?.currentGrade ?? 5);
setTargetGradeDraft(currentUser?.targetGrade ?? 3);
setTargetExamYearDraft(currentUser?.targetExamYear ?? new Date().getFullYear() + 1);
setBojHandleDraft(currentUser?.bojHandle ?? '');
showToast({ message: '저장에 실패했습니다', variant: 'danger' });
} finally {
setSavingKey((current) => (current === key ? null : current));
@@ -312,46 +303,7 @@ function ProfileBody() {
void patchProfile({ reviewIntensity: value }, 'intensity', '학습강도 변경됨 — 복습 스케줄을 새 설정에 맞게 재계산했어요');
}
function handleTargetExamYearSave() {
if (!user) return;
if (targetExamYearDraft === user.targetExamYear) return;
void patchProfile({ targetExamYear: targetExamYearDraft }, 'target-year');
}
function handleBojHandleSave() {
if (!user) return;
const trimmed = bojHandleDraft.trim();
const currentHandle = user.bojHandle ?? '';
if (trimmed === currentHandle) return;
if (trimmed && !BOJ_HANDLE_REGEX.test(trimmed)) {
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
return;
}
void patchProfile(
{ bojHandle: trimmed ? trimmed : null },
'boj-handle',
trimmed ? 'BOJ 핸들을 저장했어요' : 'BOJ 핸들을 제거했어요',
);
}
function toggleFocusUnit(unit: MathUnit) {
if (!user) return;
const current = new Set(user.focusUnits ?? []);
if (current.has(unit)) {
if (current.size === 1) {
showToast({ message: '최소 1개 단원은 유지해야 합니다', variant: 'warning' });
return;
}
current.delete(unit);
} else {
current.add(unit);
}
const next = Array.from(current);
setUser((prev) => (prev ? { ...prev, focusUnits: next } : prev));
void patchProfile({ focusUnits: next }, 'focus-units', '집중 단원을 업데이트했습니다');
}
function handleNotificationToggle(key: keyof NotificationPrefs) {
setNotifications((current) => ({
@@ -597,7 +549,6 @@ function ProfileBody() {
<BadgeRow>
<InfoBadge> {currentGradeDraft}</InfoBadge>
<InfoBadge> {targetGradeDraft}</InfoBadge>
<InfoBadge> {targetExamYearDraft}</InfoBadge>
</BadgeRow>
</AccountContent>
</AccountRow>
@@ -683,55 +634,60 @@ function ProfileBody() {
<CardSection>
<SectionHeader>
<SectionTitle> / </SectionTitle>
<SectionTitle> </SectionTitle>
</SectionHeader>
<FieldRow>
<FieldLabel> </FieldLabel>
<InlineField>
<YearInput
type="number"
value={targetExamYearDraft}
onChange={(event) => setTargetExamYearDraft(Number(event.target.value))}
/>
<Button type="button" onClick={handleTargetExamYearSave}>
</Button>
</InlineField>
</FieldRow>
<FieldRow>
<FieldLabel> </FieldLabel>
<UnitGrid>
{MATH_UNIT_OPTIONS.map(([value, label]) => {
const active = (user.focusUnits ?? []).includes(value);
return (
<UnitPill
key={value}
type="button"
$active={active}
onClick={() => toggleFocusUnit(value)}
>
<Icon name={active ? 'check-circle' : 'circle'} size={15} />
{label}
</UnitPill>
);
})}
</UnitGrid>
</FieldRow>
<FieldRow>
<FieldLabel>BOJ </FieldLabel>
<InlineField>
<HandleInput
value={bojHandleDraft}
onChange={(event) => setBojHandleDraft(event.target.value)}
placeholder="예) reloop_ps"
maxLength={20}
/>
<Button type="button" onClick={handleBojHandleSave}>
</Button>
</InlineField>
<FieldNote>//-/_ 3~20. PS .</FieldNote>
</FieldRow>
<AlgorithmExplainer>
<AlgoSection>
<AlgoLabel> </AlgoLabel>
<AlgoValue>
{PERSONA_META[user.persona].emoji} {PERSONA_META[user.persona].label}
{' + '}
{INTENSITY_CARDS.find((c) => c.value === user.reviewIntensity)?.label ?? '균형'}
</AlgoValue>
</AlgoSection>
<AlgoSection>
<AlgoLabel> </AlgoLabel>
<AlgoDesc>
ReLoop은 <strong> </strong> <strong>SM-2 </strong> .
</AlgoDesc>
<AlgoDesc>
(S) , .
.
</AlgoDesc>
</AlgoSection>
<AlgoSection>
<AlgoLabel> </AlgoLabel>
<AlgoDesc>
<strong>{PERSONA_META[user.persona].label}</strong> λ = {PERSONA_META[user.persona].lambda}/.{' '}
{user.persona === 'senior' && '기억 유지력이 높아 복습 간격이 넓어.'}
{user.persona === 'mid' && '평균적인 망각 속도로, 꾸준한 복습이 효과적이야.'}
{user.persona === 'junior' && '빠르게 잊기 때문에 더 자주 복습이 잡혀.'}
{user.persona === 'crammer' && '단기 집중형이라 가장 짧은 간격으로 자주 복습해.'}
</AlgoDesc>
</AlgoSection>
<AlgoSection>
<AlgoLabel> </AlgoLabel>
<AlgoDesc>
{user.reviewIntensity === 'strict' && '정답 확률 70% 이하로 떨어지면 바로 복습 — 가장 빡빡한 설정이야.'}
{user.reviewIntensity === 'moderate' && '정답 확률 50% 이하에서 복습 — 균형 잡힌 기본 설정이야.'}
{user.reviewIntensity === 'relaxed' && '정답 확률 35% 이하에서 복습 — 간격이 넓어 부담이 적어.'}
</AlgoDesc>
</AlgoSection>
<AlgoSection>
<AlgoLabel>3 ( )</AlgoLabel>
<AlgoDesc>
<strong>//</strong> SM-2 .{' '}
1 , , .
{user.persona === 'senior' && ' 상위권은 간격이 ×1.3배 더 넓게 적용돼.'}
{user.persona === 'junior' && ' 하위권은 간격이 ×0.8배로 조밀하게 적용돼.'}
{user.persona === 'crammer' && ' 벼락치기형은 간격이 ×0.6배로 가장 조밀해.'}
</AlgoDesc>
</AlgoSection>
</AlgorithmExplainer>
</CardSection>
</Grid>
) : null}
@@ -1442,3 +1398,43 @@ const LoadingState = styled(Card)`
justify-content: center;
color: ${theme.color.textSub};
`;
const AlgorithmExplainer = styled.div`
display: grid;
gap: 16px;
padding: 18px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid ${theme.color.borderSoftAlpha};
`;
const AlgoSection = styled.div`
display: grid;
gap: 4px;
`;
const AlgoLabel = styled.span`
color: ${theme.color.textMute};
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const AlgoValue = styled.span`
color: ${theme.color.textBright};
font-size: 15px;
font-weight: 600;
`;
const AlgoDesc = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 13px;
line-height: 1.7;
strong {
color: ${theme.color.textBright};
font-weight: 600;
}
`;