feat: 문제풀이 UI 통일 + PDF→문제집 + 통계탭 삭제

- 통계 탭 삭제 (프론트 페이지/차트/네비 항목, 백엔드 stats 모듈은 대시보드용으로 유지)
- PDF 문제 분리 → 문제집 생성으로 변경 (ProblemSet + Problem 레코드)
  - ProblemSet에 sourceType, uploadedByUserId 추가
  - POST /problem-sets/create-from-pdf 엔드포인트
  - GET /problem-sets/my-uploads, DELETE /problem-sets/:id
  - exams 페이지에 '내가 만든 문제집' 섹션
- 문제 풀이 UI 통일
  - DrawingCanvas 공통 컴포넌트 추출 (필기모드)
  - 복습 페이지에 필기모드(캔버스+펜색상) 추가
  - 다시풀기 페이지에 필기모드+메모 추가
  - 처음풀기 페이지 난이도 슬라이더→3버튼(H/M/E)+SM-2 연동

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-17 10:17:12 +09:00
parent da7dba0c0c
commit 58719e329e
55 changed files with 2404 additions and 2112 deletions

View File

@@ -0,0 +1,3 @@
{"event":"subagent_stop","timestamp":"2026-04-12T09:14:41Z","agent_id":"unknown","transcript_path":"","transcript_preview":""}
{"event":"subagent_stop","timestamp":"2026-04-16T10:01:42Z","agent_id":"unknown","transcript_path":"","transcript_preview":""}
{"event":"subagent_stop","timestamp":"2026-04-17T00:57:34Z","agent_id":"unknown","transcript_path":"","transcript_preview":""}

View File

@@ -1 +1 @@
1775919509
1776387808

View File

@@ -0,0 +1,30 @@
{"event": "task_completed", "teammate": "", "task_id": "39", "subject": "Cluster A — 동시성 연쇄 (reviews tx + review page lock)", "description": "QA-5.fix.C1 (reviews.service.submit 전체 tx) + QA-5.fix.M1 (iteration in tx) + QA-4.fix.C1 (review/pag", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:22:01Z"}
{"event": "task_completed", "teammate": "", "task_id": "40", "subject": "Cluster B — Auth 보안 게이트", "description": "QA-3.fix.C1 (email normalize) + QA-3.fix.C2 (JwtStrategy DB 재조회) + QA-3.fix.M1 (JWT_SECRET required)", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:22:03Z"}
{"event": "task_completed", "teammate": "", "task_id": "41", "subject": "Cluster C — Schema 데이터 보호", "description": "QA-5.fix.C2 (Tag cascade → SetNull, tagId nullable) + QA-5.fix.M2 (StudyLog.tag SetNull) + schema 변경", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:22:03Z"}
{"event": "task_completed", "teammate": "", "task_id": "42", "subject": "Cluster D — Frontend UX 안정화", "description": "QA-4.fix.C2 (subjects/[id] NaN guard) + QA-4.fix.M1 (history 에러 처리) + QA-4.fix.M2 (custom Modal로 con", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:22:03Z"}
{"event": "task_completed", "teammate": "", "task_id": "43", "subject": "Cluster E — 배포 인프라", "description": "QA-6.fix.C2 (postinstall) + QA-6.fix.C4 (ecosystem.config.js) + QA-6.fix.M1 (next standalone) + QA-6", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:22:04Z"}
{"event": "task_completed", "teammate": "", "task_id": "44", "subject": "Cluster F — Dev VM 대상 배포 가이드", "description": "QA-6.fix.C1 (migrations — Dev VM에서 prisma migrate dev 실행) + QA-6.fix.C3 (lockfile — pnpm install 로 생", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:22:04Z"}
{"event": "task_completed", "teammate": "", "task_id": "45", "subject": "Phase 5.1 — Gitea repo 재생성 + push", "description": "기존 reloop/reloop-v2 Gitea repo 삭제 → 새 reloop-v2 repo 생성 → local commit push", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:11Z"}
{"event": "task_completed", "teammate": "", "task_id": "46", "subject": "Phase 5.2 — Dev VM state 확인 + 백업", "description": "Dev VM SSH 접근 확인, 기존 ~/reloop 경로 백업, 이전 .env 복구용 확보", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:11Z"}
{"event": "task_completed", "teammate": "", "task_id": "47", "subject": "Phase 5.3 — Dev VM clone + install + migrate", "description": "Dev VM에서 git clone → pnpm install (backend/frontend) → prisma migrate dev --name init + seed", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:12Z"}
{"event": "task_completed", "teammate": "", "task_id": "48", "subject": "Phase 5.4 — Build + standalone 배치", "description": "backend pnpm build + frontend NEXT_PUBLIC_API_URL 환경변수로 pnpm build + .next/static과 public을 standalon", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:12Z"}
{"event": "task_completed", "teammate": "", "task_id": "49", "subject": "Phase 5.5 — PM2 스왑", "description": "기존 reloop PM2 프로세스 확인 → stop/delete → pm2 start ecosystem.config.js → pm2 save", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:13Z"}
{"event": "task_completed", "teammate": "", "task_id": "50", "subject": "Phase 5.6 — Smoke test + migrations commit", "description": "reloop-api/reloop health curl 확인, 회원가입→온보딩→복습 플로우 QA, 생성된 migrations/lockfile 커밋 후 Gitea push", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:13Z"}
{"event": "task_completed", "teammate": "", "task_id": "38", "subject": "Phase 5 — Polish + 배포", "description": "styled-components 디자인 시스템 정리 (글리치 없이 dark 톤 + accent 색), 모바일 bottom nav, PWA manifest, 에러 handling, ", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T15:45:13Z"}
{"event": "task_completed", "teammate": "", "task_id": "51", "subject": "Phase 6 harness-review — Reviewer 감사 완료", "description": "Independent 2nd opinion via claude-code-harness:code-reviewer. verdict=REQUEST_CHANGES. major 1, min", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T16:36:04Z"}
{"event": "task_completed", "teammate": "", "task_id": "52", "subject": "Phase 6 fix — [major] problemId 서버 검증", "description": "study-logs.service.create() 에 problemId 존재 확인 추가. tagId 와 동일한 패턴. 없으면 NotFoundException. Reviewer 지적", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T16:42:51Z"}
{"event": "task_completed", "teammate": "", "task_id": "53", "subject": "Phase 6 fix — [minor] 마스터리 이미 도달 카피", "description": "iterationsToTarget === 0 특수 분기. '이미 목표 P 에 도달' 메시지.", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T16:44:00Z"}
{"event": "task_completed", "teammate": "", "task_id": "54", "subject": "Phase 6 fix — [minor] 토글 aria-pressed", "description": "stats 마스터리/망각 토글, pricing 월/연 토글, study 탭에 aria-pressed 추가. WCAG 4.1.2.", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T16:45:12Z"}
{"event": "task_completed", "teammate": "", "task_id": "55", "subject": "Phase 6 fix — [minor] 데모 credential 표시 가드", "description": "Landing 페이지의 demo@reloop.local 표시에 TODO 주석. Phase 7 런칭 시 제거 예정.", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T16:46:25Z"}
{"event": "task_completed", "teammate": "", "task_id": "56", "subject": "Phase 6 tracking — [minor] Subscription tier enforcement (Phase 7 연동)", "description": "mastery-path + problem-sets 의 tier gating 이 현재 없음. Phase 7 결제 연동 시 함께 구현. 지금은 TODO 주석만 남김.", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T16:47:35Z"}
{"event": "task_completed", "teammate": "", "task_id": "57", "subject": "Phase 7C — 디자인 토큰 + primitives 증축 (5 tasks)", "description": "7C.1 theme.ts 확장 / 7C.2 Phosphor React Icon 어댑터 / 7C.3 Inter+JetBrains Mono next/font / 7C.4 primiti", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T18:24:27Z"}
{"event": "task_completed", "teammate": "", "task_id": "58", "subject": "Phase 7A — KICE PDF 파이프라인 (8 tasks)", "description": "7A.1 schema 확장 / 7A.2 스캐너 / 7A.3 정답표 파서 / 7A.4 본문 파서 / 7A.5 공통 지문 / 7A.6 needsReview 가드 / 7A.7 upser", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T18:46:47Z"}
{"event": "task_completed", "teammate": "", "task_id": "59", "subject": "Phase 7D — 12 page redesign via Codex harness", "description": "12 tasks: 7D.1 AppShell → 7D.2-7D.5/7-12 parallel-eligible → 7D.6 (after 7D.5). Delegate each to Cod", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T20:22:38Z"}
{"event": "task_completed", "teammate": "", "task_id": "61", "subject": "7F.5 — OCR fallback for needsReview edge cases", "description": "Vision API fallback for problems still flagged needsReview after 7F.4. Per-problem image render + st", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T21:30:52Z"}
{"event": "task_completed", "teammate": "", "task_id": "1", "subject": "7. 밀린 복습 자동 다음날 이월", "description": "복습날에 못한 문제(pending + scheduledAt이 지난 것)를 자동으로 다음날로 reschedule하는 로직 구현. 백엔드 reviews.service.ts에 resch", "agent_id": "", "agent_type": "", "timestamp": "2026-04-16T23:50:14Z"}
{"event": "task_completed", "teammate": "", "task_id": "2", "subject": "8. 페르소나별 복습 알고리즘 검증", "description": "학습설정에서 페르소나(senior/mid/junior/crammer) 변경 시 기존 pending 복습 스케줄이 재계산되는지 확인. 필요하면 페르소나 변경 시 pending 스케줄", "agent_id": "", "agent_type": "", "timestamp": "2026-04-16T23:50:14Z"}
{"event": "task_completed", "teammate": "", "task_id": "3", "subject": "9. PDF 문제집 OCR 분리 + 태그 배정", "description": "모의고사 PDF 업로드 → OCR로 문제 단위 분리 → 각 문제에 태그 배정 + 선택적으로 복습 큐 등록. 기존 parsing 파이프라인(codex-vision OCR) 활용해서 ", "agent_id": "", "agent_type": "", "timestamp": "2026-04-16T23:50:15Z"}
{"event": "task_completed", "teammate": "", "task_id": "4", "subject": "운영서버 배포", "description": "3개 기능 구현 완료 후 git commit → push → mailu-dev(10.10.10.169)에 배포 + 스모크 테스트", "agent_id": "", "agent_type": "", "timestamp": "2026-04-16T23:52:08Z"}
{"event": "task_completed", "teammate": "", "task_id": "9", "subject": "14. PDF 문제 분리 → 이미지 크롭 방식 + 해설지 정답 자동 태깅", "description": "OCR 텍스트 파싱 대신 bbox 기반 이미지 크롭으로 전환. 문제 번호 위치 감지 → pdftoppm으로 개별 문제 이미지 크롭 → 이미지 URL 저장. 해설지 PDF 동시 업로", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T00:34:53Z"}
{"event": "task_completed", "teammate": "", "task_id": "10", "subject": "15. 복습 시 정답 표시", "description": "복습 화면에서 정답(chosenAnswer or answerNumber)을 확인할 수 있도록 수정. StudyLog에 correctAnswer 필드 추가 or 기존 데이터 활용.", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T00:34:53Z"}
{"event": "task_completed", "teammate": "", "task_id": "11", "subject": "통계 탭 삭제", "description": "frontend /stats 페이지, backend /stats 모듈, 관련 차트 컴포넌트, SideNav/BottomNav 메뉴 항목 삭제", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T01:03:58Z"}

View File

@@ -0,0 +1,2 @@
{"event": "post_compact", "has_wip": "true", "has_snapshot": "true", "has_handoff": "true", "timestamp": "2026-04-12T09:14:41Z"}
{"event": "post_compact", "has_wip": "true", "has_snapshot": "true", "has_handoff": "true", "timestamp": "2026-04-17T00:57:35Z"}

View File

@@ -0,0 +1,28 @@
{
"task_id": "7A.1",
"title": "Prisma 스키마 확장 — KICE 텍스트 파싱 필드",
"goal": "ProblemSet/Problem 에 KICE 파싱 결과를 담을 필드 추가 + 공통 지문용 Passage 모델 신규",
"checks": [
"ProblemSet 에 examRound String @default('sat') 추가 (향후 mock-jun/mock-sep 지원)",
"ProblemSet 에 audioUrls Json? 추가 (영어 듣기 mp3 경로 배열용)",
"Problem 에 bodyText String? @db.Text 추가",
"Problem 에 choices Json? 추가 ({\"1\":\"...\",\"2\":\"...\"} 구조)",
"Problem 에 answerNumber Int? 추가 (1~5 또는 주관식 정수)",
"Problem 에 passageId Int? 추가 + passage Passage? relation",
"Problem 에 needsReview Boolean @default(false) 추가",
"신규 model Passage { id Int @id, problemSetId Int, startNumber Int, endNumber Int, bodyText String @db.Text, problemSet ProblemSet @relation, problems Problem[], createdAt DateTime @default(now()), @@index([problemSetId]) }",
"ProblemSet 에 passages Passage[] relation 추가",
"migration 이름: kice_text_fields (prisma migrate dev --name kice_text_fields)",
"prisma generate 재실행으로 @prisma/client 타입 업데이트"
],
"non_goals": [
"기존 difficulty / baseCorrectRate / topic 필드 제거 — 유지",
"UserProblemSet 생성 (7B 범위)",
"seed 수정 (7A.8)"
],
"runtime_validation": ["cd backend && pnpm exec prisma migrate dev --name kice_text_fields"],
"risk_flags": ["migration"],
"reviewer_profile": "runtime",
"effort_score": 2,
"deliverable": "backend/prisma/schema.prisma + backend/prisma/migrations/YYYYMMDDHHMMSS_kice_text_fields/"
}

View File

@@ -0,0 +1,27 @@
{
"task_id": "7A.2",
"title": "kice-import.ts 스캐너 (dry-run)",
"goal": "data/kice/{year}/{subject}/ 디렉토리를 순회해서 각 파일을 (year, subject, role) 로 분류. dry-run 모드에선 파일만 나열, 실제 파싱 없음.",
"checks": [
"backend/scripts/kice-import.ts 신규 생성",
"CLI 옵션 파싱: --dry-run (파싱 없이 나열만), --year=YYYY (특정 연도만), --subject=국어|영어|한국사|생활과윤리 (특정 과목만)",
"DATA_DIR 상수: '../data/kice' 기준 (backend/ 에서 실행하면 ../data/kice)",
"SUBJECT_MAP 상수: '국어', '영어', '한국사' 디렉토리명 + 생활과윤리는 '사회탐구/사회탐구영역_문제지/01 생활과 윤리_*.pdf' 패턴",
"파일 role 분류: '문제지' (홀수형/짝수형 중 1개 우선), '정답표', '듣기대본' (영어), '듣기음원' (스킵)",
"scanSets() 함수: 디렉토리 순회 → Array<{ year, subject, role, filepath, sizeKB }>",
"--dry-run 실행 시 console 에 tree 형식으로 출력: '2025 / 국어 / 문제지(홀수형) — 1234KB'",
"package.json 의 scripts 에 'cli:kice-import': 'ts-node scripts/kice-import.ts' 추가"
],
"non_goals": [
"실제 pdf-parse 호출 (7A.3 이후)",
"DB 접근",
"생활과 윤리 이외 탐구 과목 포함"
],
"runtime_validation": [
"cd backend && pnpm cli:kice-import --dry-run"
],
"risk_flags": [],
"reviewer_profile": "runtime",
"effort_score": 1,
"deliverable": "backend/scripts/kice-import.ts + backend/package.json scripts 추가"
}

View File

@@ -0,0 +1,27 @@
{
"task_id": "7A.3",
"title": "정답표 PDF 파서",
"goal": "평가원 정답표 PDF 를 pdf-parse 로 텍스트 추출 → 정규식 파싱 → Array<{ number, answerNumber }> 생성",
"checks": [
"backend/scripts/kice-parser-answers.ts 또는 kice-import.ts 내 함수 parseAnswerTable(buffer: Buffer): Promise<Array<{number, answerNumber}>>",
"pdf-parse 설치: 기존 backend/package.json 에 pdf-parse 추가 (pnpm add pdf-parse)",
"pdf-parse 가 extractedText 를 반환하면 원문자 ①②③④⑤ 를 1~5 로 normalize",
"정규식: /(\\d{1,2})\\s*번?\\s*([①②③④⑤])/ 또는 /(\\d{1,2})\\s*([①②③④⑤])/ 또는 테이블 raw 기반 다양한 패턴 시도",
"반환: 정렬된 number 순서 배열, 중복 제거",
"실측: 2025 국어 정답표 (45문항), 2026 국어 (45), 2025/2026 한국사 (20), 2025/2026 영어 (45), 2025/2026 생활과 윤리 (20) 에서 각각 number 1부터 끝까지 빠짐없이 추출되는지 검증",
"스폿체크: 2026 국어 1번, 15번, 45번 답을 수동 PDF 확인과 비교",
"로그에 '⚠ 정답표 파싱 실패: N문항' 형식으로 누락 표시"
],
"non_goals": [
"수학/과탐 정답표 (수식 포함, 현재 범위 아님)",
"공식 정답률 데이터 추출",
"ML/LLM 사용"
],
"runtime_validation": [
"cd backend && pnpm cli:kice-import --year=2026 --subject=국어 --only=answer"
],
"risk_flags": [],
"reviewer_profile": "runtime",
"effort_score": 2,
"deliverable": "backend/scripts/kice-parser-answers.ts + pdf-parse 의존 추가"
}

View File

@@ -0,0 +1,29 @@
{
"task_id": "7A.4",
"title": "문제지 PDF 본문 분할 파서",
"goal": "평가원 문제지 PDF 에서 각 문항의 본문 + 5 선택지를 분리",
"checks": [
"backend/scripts/kice-parser-problems.ts 또는 kice-import.ts 내 함수 parseProblemPaper(buffer: Buffer): Promise<Array<{number, bodyText, choices: {[key: string]: string}}>>",
"pdf-parse 로 전체 텍스트 추출",
"문항 시작 감지: /\\n\\s*(\\d{1,2})\\.\\s+/ — 1번~45번",
"각 문항의 본문 span: 현재 문항 번호부터 다음 문항 번호 직전까지",
"본문 내부에서 선택지 분리: /[①②③④⑤]/ 5개 위치 찾아서 선택지 영역 분리. ① 앞쪽은 bodyText, ①~⑤ 각각은 choices['1'|'2'|'3'|'4'|'5']",
"원문자 → 숫자 키 정규화",
"공백/줄바꿈 trim, 여러 줄 공백 축약",
"실측: 2026 국어 정답표 기준 45 문항 모두 본문 + 5 choices 로 분리. 각 choice 문자열 >= 1자",
"실측: 2026 영어 45 문항 (지문 영어 + 선택지 한글/영어) 동일",
"한자/특수기호가 있어도 정상 분리 (한국사 사료)"
],
"non_goals": [
"수학/과탐",
"이미지/그래프 추출",
"주관식 처리 (5지선다 외)"
],
"runtime_validation": [
"cd backend && pnpm cli:kice-import --year=2026 --subject=국어 --only=problems --sample=1"
],
"risk_flags": [],
"reviewer_profile": "runtime",
"effort_score": 2,
"deliverable": "backend/scripts/kice-parser-problems.ts"
}

View File

@@ -0,0 +1,26 @@
{
"task_id": "7A.5",
"title": "공통 지문 파서 (Passage 분리)",
"goal": "평가원 국어/영어/사탐에 자주 등장하는 [N~M] 공통 지문 표기를 감지해서 별도 Passage 레코드로 저장",
"checks": [
"parseProblemPaper 결과에서 후처리로 passage detection 추가",
"정규식: /\\[(\\d+)\\s*~\\s*(\\d+)\\]/ — '[1~3]', '[ 1 ~ 3 ]' 모두 매칭",
"[N~M] 바로 위의 텍스트 블록을 passage.bodyText 로 추출 (시작 경계는 직전 문항 끝, 또는 섹션 구분자 '※')",
"추출된 passage 는 parseProblemPaper 반환값에 추가: { passages: Array<{ startNumber, endNumber, bodyText }>, problems: [...] }",
"problem 의 passageId (임시 인덱스) 를 passage 와 연결 — 번호 범위가 맞으면 연결",
"2026 국어: 최소 3개 passage set 감지 (보통 독서 3 지문 + 문학 3~4 지문)",
"각 passage 의 bodyText 길이 >= 50자",
"passage 감지 실패 시 problem.passageId = null 로 유지"
],
"non_goals": [
"passage 내부 각주/표 파싱",
"passage-문항 정확한 분리 (둥그스름한 경계 허용)"
],
"runtime_validation": [
"cd backend && pnpm cli:kice-import --year=2026 --subject=국어 --only=problems --sample=1"
],
"risk_flags": [],
"reviewer_profile": "runtime",
"effort_score": 1,
"deliverable": "kice-parser-problems.ts 의 passage 추출 기능 확장"
}

View File

@@ -0,0 +1,27 @@
{
"task_id": "7A.6",
"title": "파싱 실패 needsReview 가드",
"goal": "파싱 엣지케이스를 needsReview=true 로 플래그해서 수동 보정 대상 추적",
"checks": [
"validateProblem(problem): problem.needsReview = true 조건 아래 중 하나라도 해당:",
" - bodyText 가 빈 문자열 또는 10자 미만",
" - choices 의 key 가 5개 미만 (5지선다 아님)",
" - choices 값 중 빈 문자열 존재",
" - answerNumber 가 null 이거나 1~5 범위 밖",
" - number 중복 (같은 번호 2회 감지)",
"import 로그: '⚠ [year subject] Problem N needsReview: reason' 형식",
"import 최종 요약에 needsReview 총 개수 출력",
"2026 한국사 샘플 파싱 후 needsReview 개수를 정확히 리포트 (수동으로 그 문항들 PDF 와 비교 가능)"
],
"non_goals": [
"자동 수정 (수동 보정만)",
"needsReview 항목 필터링해서 제외 (모든 문항 upsert 후 flag 만)"
],
"runtime_validation": [
"cd backend && pnpm cli:kice-import --year=2026 --subject=한국사 --dry-parse"
],
"risk_flags": [],
"reviewer_profile": "runtime",
"effort_score": 0,
"deliverable": "kice-import.ts 또는 kice-parser-problems.ts 의 validate 함수"
}

View File

@@ -0,0 +1,28 @@
{
"task_id": "7A.7",
"title": "ProblemSet + Problem upsert 로직",
"goal": "파싱 결과를 Prisma 로 DB 에 멱등하게 저장. 2회 실행해도 중복 없이 같은 상태.",
"checks": [
"importKiceData(year, subject, parsed) 함수 — 트랜잭션 안에서:",
" 1. ProblemSet.upsert (where { year_examType_subjectName: { year, examType: 'sat', subjectName } })",
" 2. parsed.passages 먼저 upsert (where by unique composite or delete+recreate)",
" 3. parsed.problems 를 Problem.upsert (where { problemSetId_number: { problemSetId, number } })",
" 4. Problem 의 passageId 는 step 2 에서 생성된 Passage.id 로 매핑",
"ProblemSet.audioUrls: 영어 과목이면 data/kice/{year}/영어/영어영역*듣기평가음원/*.mp3 상대경로 배열, 그 외 null",
"멱등성: 같은 --year --subject 2회 실행 후 Problem.count(), Passage.count() 동일",
"import 최종 리포트: '✓ 2026 국어: 45 problems, 6 passages, 0 needsReview'",
"에러 처리: PDF 없으면 warn 후 다음 과목으로 (실패해도 전체 중단 X)",
"CLI 옵션: --year=all --subject=all 전체 실행"
],
"non_goals": [
"UserProblemSet 반영 (7B)",
"기존 DEMO_PROBLEMSET 제거 (7A.8)"
],
"runtime_validation": [
"cd backend && pnpm cli:kice-import --year=2026 --subject=국어 && pnpm exec prisma studio 로 ProblemSet 확인"
],
"risk_flags": ["migration"],
"reviewer_profile": "runtime",
"effort_score": 2,
"deliverable": "backend/scripts/kice-import.ts 의 importKiceData + Prisma 쿼리 통합"
}

View File

@@ -0,0 +1,24 @@
{
"task_id": "7A.8",
"title": "seed.ts 의 2024 DEMO_PROBLEMSET 제거",
"goal": "기존 2024 수능 수학 더미 10문제 (P6-T4 에서 생성) 를 seed 에서 제거. KICE import 와 충돌 방지.",
"checks": [
"backend/prisma/seed.ts 에서 DEMO_PROBLEMSET 상수 정의 블록 삭제",
"main() 함수의 ProblemSet upsert + Problem upsert 블록 삭제",
"seed.ts 가 User + Subject + Tag 생성만 수행하도록 유지",
"seed 재실행: 기존 DB 에 있는 '2024학년도 대학수학능력시험 수학' ProblemSet 은 수동 삭제 필요 (DELETE SQL 스크립트 or mailu-dev 에서 수동)",
"seed.ts 에 DEMO_PROBLEMSET 문자열 검색 → 0 건",
"seed 실행 시 Subject/Tag 는 idempotent 로 유지, 오류 없음"
],
"non_goals": [
"기존 2024 ProblemSet 데이터 실제 SQL 삭제 (수동)",
"User 데모 계정 변경"
],
"runtime_validation": [
"cd backend && pnpm prisma:seed"
],
"risk_flags": [],
"reviewer_profile": "runtime",
"effort_score": 0,
"deliverable": "backend/prisma/seed.ts"
}

View File

@@ -0,0 +1,64 @@
{
"schema_version": "sprint-contract.v1",
"generated_at": "2026-04-11T19:03:58.016Z",
"source": {
"plans_file": "Plans.md",
"task_id": "7D.1"
},
"task": {
"id": "7D.1",
"title": "**AppShell (사이드바)** — design-01.html 의 `aside.w-64` 레이아웃 (256px, 다크 bgDeep, indigo radial glow blur-80px, backdrop-blur-xl). 로고 indigo→violet gradient + Phosphor `ph-infinity`, nav 5 항목(대시보드/복습/학습 기록/통계/과목 및 태그) Phosphor icons, 활성 라우트 bg-white/5 + border-white/5, 하단 user card (아바타 + nickname + plan badge + caret 드롭다운). 모바일에서는 BottomNav 로 대체",
"definition_of_done": "기존 SideNav.tsx 교체, `/dashboard` 진입 시 새 사이드바 렌더, 활성 라우트 강조, 로그아웃 클릭 동작",
"depends_on": [
"7C.5"
],
"status_at_generation": "cc:WIP"
},
"contract": {
"checks": [
{
"id": "dod-primary",
"source": "Plans.md.DoD",
"description": "기존 SideNav.tsx 교체, `/dashboard` 진입 시 새 사이드바 렌더, 활성 라우트 강조, 로그아웃 클릭 동작"
},
{
"id": "check-styled",
"source": "manual",
"description": "styled-components 만 사용, Tailwind 클래스 금지"
},
{
"id": "check-phosphor",
"source": "manual",
"description": "components/ui/Icon.tsx (Phosphor) 사용"
},
{
"id": "check-preserve-auth",
"source": "manual",
"description": "useRouter/usePathname/hasToken/api 기존 로직 보존"
},
{
"id": "check-design-parity",
"source": "manual",
"description": "design-01.html aside 구조 1:1 반영 (width 256px, radial glow, gradient logo, active nav highlighting)"
}
],
"non_goals": [],
"runtime_validation": [
"cd frontend && pnpm exec tsc --noEmit"
],
"browser_validation": [],
"risk_flags": []
},
"review": {
"status": "approved",
"reviewer_profile": "static",
"browser_mode": null,
"route": null,
"reviewer_notes": [
"manually approved for codex delegation"
],
"approved_at": "2026-04-11T19:04:36.631Z",
"gaps": [],
"followups": []
}
}

View File

@@ -0,0 +1,464 @@
{
"version": "2.0.0",
"legacy_version": "1.0.0",
"artifactType": "structured-handoff",
"timestamp": "2026-04-17T00:55:24.687Z",
"sessionId": "",
"previous_state": {
"summary": "Before compaction: 20 recent edit(s)",
"session_state": null,
"plan_counts": {
"total": 24,
"wip": 0,
"blocked": 0,
"recent_edits": 20
}
},
"next_action": {
"taskId": "7B.1",
"task": "Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)",
"dod": "migration 통과, User/ProblemSet 양방향 relation 정의됨",
"depends": "7A.1",
"status": "cc:TODO",
"source": "Plans.md",
"priority": "normal",
"summary": "Continue 7B.1 Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)"
},
"open_risks": [
{
"severity": "medium",
"kind": "verification",
"summary": "20 recent edit(s) should be re-validated after resume",
"detail": ".claude/sessions/.last_inbox_check, .claude/state/plans-state.json, .claude/state/tool-failure-counter.txt, .claude/logs/subagent-history.jsonl, .claude/state/breezing-timeline.jsonl"
}
],
"failed_checks": [],
"decision_log": [
{
"timestamp": "2026-04-17T00:55:24.687Z",
"actor": "pre-compact-save",
"decision": "canonical_handoff_artifact_written",
"rationale": "Persist a stable JSON artifact in .claude/state for long-running session handoff."
},
{
"timestamp": "2026-04-17T00:55:24.687Z",
"actor": "pre-compact-save",
"decision": "legacy_snapshot_mirrored",
"rationale": "Keep precompact-snapshot.json for backward compatibility with older hooks."
},
{
"timestamp": "2026-04-17T00:55:24.687Z",
"actor": "pre-compact-save",
"decision": "next_action_selected",
"rationale": "Continue 7B.1 Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`) (source: Plans.md)"
}
],
"context_reset": {
"policy": {
"mode": "auto",
"dryRun": false,
"thresholds": {
"wipTasks": 4,
"blockedTasks": 1,
"recentEdits": 8,
"failedChecks": 1,
"sessionAgeMinutes": 120
}
},
"recommended": true,
"summary": "Context reset recommended (auto): 20 recent edit count exceed threshold 8",
"reasons": [
"20 recent edit count exceed threshold 8"
],
"candidates": [
{
"key": "wip_tasks",
"label": "WIP task count",
"actual": 0,
"threshold": 4,
"triggered": false
},
{
"key": "blocked_tasks",
"label": "blocked task count",
"actual": 0,
"threshold": 1,
"triggered": false
},
{
"key": "recent_edits",
"label": "recent edit count",
"actual": 20,
"threshold": 8,
"triggered": true
},
{
"key": "failed_checks",
"label": "failed check count",
"actual": 0,
"threshold": 1,
"triggered": false
},
{
"key": "session_age_minutes",
"label": "session age (minutes)",
"actual": 0,
"threshold": 120,
"triggered": false
}
],
"counters": {
"wipTasks": 0,
"blockedTasks": 0,
"recentEdits": 20,
"failedChecks": 0,
"sessionAgeMinutes": null
}
},
"continuity": {
"plugin_first_workflow": true,
"resume_aware_effort_continuity": true,
"effort_hint": "medium",
"active_skill": null,
"summary": "plugin-first workflow: enabled; resume-aware effort continuity: medium; next_task=7B.1"
},
"planItems": [
{
"taskId": "7B.1",
"title": "Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)",
"dod": "migration 통과, User/ProblemSet 양방향 relation 정의됨",
"depends": "7A.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.2",
"title": "backend `me/problem-sets` module: `GET /api/me/problem-sets` (내 추가분 list), `POST /api/me/problem-sets` (`{ problemSetId }`), `DELETE /api/me/problem-sets/:id` (soft archive)",
"dod": "데모 계정으로 3개 API 모두 200/201/204 응답. 중복 추가 시 409",
"depends": "7B.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.3",
"title": "frontend `lib/api.ts` 에 `UserProblemSet`, `addProblemSet`, `listMyProblemSets`, `removeProblemSet` 타입/헬퍼 추가",
"dod": "타입 에러 없이 컴파일, stats 페이지에서 호출 가능",
"depends": "7B.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.4",
"title": "frontend `/study` 페이지 탭 재설계: [내 문제집 / 카탈로그 / 직접 입력] 3 탭. \"카탈로그\" 에 전체 ProblemSet 목록 + 각 카드에 \"+ 내 목록에 추가\" 버튼. \"내 문제집\" 에 추가한 것만 표시",
"dod": "카탈로그에서 카드 클릭 → 내 목록 탭에 즉시 반영. 중복 클릭 방지",
"depends": "7B.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.5",
"title": "\"내 문제집\" 탭 상세: 선택한 ProblemSet → 문항 리스트 → 각 문항 \"풀기\" 버튼 → 기존 `/study/form?problemId=N` 으로 이동해 결과 입력",
"dod": "데모 계정에서 2026 수학 → 15번 클릭 → 결과 기록 → 복습 큐 진입까지 전 플로우 통과",
"depends": "7B.4",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.1",
"title": "Prisma 스키마: `MathUnit` enum, `ProblemSet.mathUnit MathUnit?`, `User.bojHandle String? @db.VarChar(32)`, `User.focusUnits Json?`. migration `phase10_math_units_and_ps` 생성",
"dod": "`prisma migrate dev --name phase10_math_units_and_ps` 성공, `@prisma/client` 재생성, 기존 행 NULL 기본값",
"depends": "-",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.2",
"title": "비수학 데이터 wipe 스크립트 `backend/scripts/wipe-non-math.ts` — `ProblemSet where subjectName != '수학'` 연쇄 삭제(Assignment/AssignmentSubmission, StudyLog, ReviewSchedule, Problem, Passage 포함). 실행 전 mysqldump 백업을 `backups/` 에 저장",
"dod": "dry-run 시 삭제 대상 수 출력, `--apply` 시 실제 삭제 후 `ProblemSet count where subjectName='수학'` 만 남음",
"depends": "10A.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.3",
"title": "`kice-import.ts` 리팩터: `TARGET_SUBJECTS = ['수학']`, 30문항을 1~22 = common, 23~30 = 선택 × 3 단원으로 split 하여 4 ProblemSet 생성. `ProblemSet.title` 은 `YYYY학년도 수능 수학 (공통)` 형태",
"dod": "`pnpm cli:kice-import --dry-run --year=2026` 시 common 22 + 확통 8 + 미적 8 + 기하 8 = 46 문항 리포트",
"depends": "10A.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.4",
"title": "`backend/src/problem-sets/parsing/strategies/kice.ts` 및 관련 OCR 프롬프트에서 비수학 분기 제거. `shared.ts`·`ocr-fix.ts`·`seed.ts` 에서 비수학 과목 참조 삭제",
"dod": "`grep -rE \"국어|영어|한국사|생활과 윤리|사회탐구\" backend/src backend/scripts` 결과 0",
"depends": "10A.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.5",
"title": "`data/kice/{2025,2026}/{국어,영어,한국사,사회탐구}/` 삭제 + `.gitignore` 정리. `backend/uploads/problems/{year}/{비수학 과목}/` 도 삭제",
"dod": "디스크에 수학 PDF + 수학 이미지 crop 만 남음",
"depends": "10A.4",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.1",
"title": "Prisma: `model PsProblem { id Int, bojId Int @unique, title, level Int, tags Json, acceptedUserCount Int?, averageTries Float?, updatedAt }`, `model PsBookmark { userId, psProblemId, createdAt @@unique([userId,psProblemId]) }`, `StudyLog.psProblemId Int?` FK 추가. migration `phase10_ps_tables`",
"dod": "`prisma migrate dev` 성공, 관계 매핑 검증",
"depends": "10A.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.2",
"title": "`backend/src/ps/solved-ac.client.ts` — axios 인스턴스(`https://solved.ac/api/v3`, User-Agent 명시, 타임아웃 10s, 지수 백오프 3회). 메서드: `searchProblems(query, page)`, `getProblem(bojId)`, `getUser(handle)`, `getUserSolvedProblems(handle, page)`. 내부 rate-limit (초당 3req)",
"dod": "단위 테스트: mocked axios 로 4 메서드 응답 파싱 정상",
"depends": "10B.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.3",
"title": "`PsModule` + `PsService` + `PsController` — endpoints: `GET /api/ps/search?q=&level=&page=`, `GET /api/ps/problems/:bojId`, `POST /api/ps/bookmarks { bojId }`, `DELETE /api/ps/bookmarks/:bojId`, `GET /api/ps/bookmarks`, `POST /api/ps/sync { bojHandle? }` (핸들 없으면 `User.bojHandle` 사용). JWT guard + throttler",
"dod": "Postman/curl 6 endpoint 200, sync 실행 시 PsProblem upsert + 시스템 Subject \"PS\" 생성 + StudyLog bulk insert, 멱등성 확인(중복 sync 시 같은 BOJ 문제 StudyLog 추가 생성 안 함)",
"depends": "10B.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.4",
"title": "Sync 멱등성 저장소: `PsSyncState { userId @unique, lastSyncedAt, lastSolvedCount }` 추가해 마지막 sync 이후 새로 맞힌 문제만 가져옴. 초기 sync 는 `top_100` 기준 상위 100개로 제한(과도한 StudyLog 폭주 방지)",
"dod": "1차 sync 후 `PsSyncState.lastSolvedCount` 저장, 2차 sync 는 차분만 insert",
"depends": "10B.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.1",
"title": "`frontend/src/app/onboarding/page.tsx` — GoalType 옵션 라벨/설명 교체. `math-suneung` 선택 시 4 단원 체크박스 그룹 노출, `ps` 선택 시 `bojHandle` 인풋(정규식 검증 `^[a-zA-Z0-9_-]{3,20}$`) 노출",
"dod": "각 GoalType 분기 UI 정상, 저장 후 `/auth/me` 응답에 `focusUnits` 또는 `bojHandle` 반영",
"depends": "10B.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.2",
"title": "`frontend/src/lib/api.ts` + `MeUser` 타입: `bojHandle`, `focusUnits: MathUnit[]`, `GoalType` 유니언 갱신. `api/me` PATCH payload 확장",
"dod": "타입 에러 0, API PATCH 200",
"depends": "10C.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.3",
"title": "전역 과목 리스트 정리 — `subjects/page.tsx`, `exams/page.tsx`, `review/page.tsx`, `review/history/page.tsx`, `dashboard`, `school/*` 에서 비수학 하드코딩 참조 제거. `exam/shared.ts::getExamDurationSeconds` → 수학 100분 고정. `SideNav` 에서 국어/영어/한국사 링크 제거",
"dod": "`grep -rE \"국어|영어|한국사|생활과 윤리\" frontend/src` = 0",
"depends": "10C.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.4",
"title": "`frontend/src/app/ps/` 신규 섹션 — `page.tsx`(검색 + 필터 + 북마크 버튼), `bookmarks/page.tsx`(북마크 목록), `sync/page.tsx`(핸들 동기화 버튼 + 상태). BottomNav/SideNav 에 `PS` 진입점 추가",
"dod": "4 경로 200 + search/bookmark/sync e2e 수동 검증 통과",
"depends": "10C.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.5",
"title": "`landing`/`pricing`/`dashboard` 카피 업데이트 — \"수능 국어/영어…\" 문구를 \"수학 수능 + PS\" 로 교체, 아카이브 design HTML 과 docs 에서도 혼동 없도록 정리",
"dod": "`grep -rE \"국어|영어|한국사|생활과 윤리\" frontend/src docs README.md` = 0 (아카이브 `.claude/archive/` 는 제외)",
"depends": "10C.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.1",
"title": "로컬 커밋 + push → Gitea `hanarang/reloop-v2 master`. commit 단위는 10A / 10B / 10C 별로 나눔",
"dod": "`git push` 성공, `tea pr list` 없이 master 직접 반영",
"depends": "10C.5",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.2",
"title": "`ssh dev@10.10.10.169` → `cd ~/reloop-v2 && git pull`. backend 에서 `mysqldump reloop_v2 > backups/phase10-pre.sql` 실행 후 `pnpm --filter backend exec prisma migrate deploy` → `pnpm --filter backend build`",
"dod": "마이그레이션 적용됨, dist/main.js 빌드 성공, DB 백업 존재",
"depends": "10D.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.3",
"title": "비수학 wipe 스크립트 운영 실행 — `node backend/dist/scripts/wipe-non-math.js --apply`. 이어서 `pnpm cli:kice-import --year=2025 && --year=2026` 로 수학 8 ProblemSet 재생성",
"dod": "DB 에 수학 8 ProblemSet / 92 problems, 비수학 0",
"depends": "10D.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.4",
"title": "Frontend 빌드 + standalone copy + `pm2 reload reloop-web reloop-api --update-env`",
"dod": "PM2 status online, `curl https://reloop-api.nabomhalang.co.kr/health` 200, `curl https://reloop.nabomhalang.co.kr` 200",
"depends": "10D.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.5",
"title": "스모크 테스트 — 온보딩 `math-suneung` 플로우 + 단원 선택 저장, `ps` 플로우 + 더미 BOJ 핸들 sync, `/ps/search` 검색, `/exams` 에 수학 4단원 표시, `/review` 에 비수학 흔적 없음",
"dod": "5 시나리오 수동 통과 스크린샷 또는 로그 캡처",
"depends": "10D.4",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
}
],
"wipTasks": [
"Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)",
"backend `me/problem-sets` module: `GET /api/me/problem-sets` (내 추가분 list), `POST /api/me/problem-sets` (`{ problemSetId }`), `DELETE /api/me/problem-sets/:id` (soft archive)",
"frontend `lib/api.ts` 에 `UserProblemSet`, `addProblemSet`, `listMyProblemSets`, `removeProblemSet` 타입/헬퍼 추가",
"frontend `/study` 페이지 탭 재설계: [내 문제집 / 카탈로그 / 직접 입력] 3 탭. \"카탈로그\" 에 전체 ProblemSet 목록 + 각 카드에 \"+ 내 목록에 추가\" 버튼. \"내 문제집\" 에 추가한 것만 표시",
"\"내 문제집\" 탭 상세: 선택한 ProblemSet → 문항 리스트 → 각 문항 \"풀기\" 버튼 → 기존 `/study/form?problemId=N` 으로 이동해 결과 입력",
"Prisma 스키마: `MathUnit` enum, `ProblemSet.mathUnit MathUnit?`, `User.bojHandle String? @db.VarChar(32)`, `User.focusUnits Json?`. migration `phase10_math_units_and_ps` 생성",
"비수학 데이터 wipe 스크립트 `backend/scripts/wipe-non-math.ts` — `ProblemSet where subjectName != '수학'` 연쇄 삭제(Assignment/AssignmentSubmission, StudyLog, ReviewSchedule, Problem, Passage 포함). 실행 전 mysqldump 백업을 `backups/` 에 저장",
"`kice-import.ts` 리팩터: `TARGET_SUBJECTS = ['수학']`, 30문항을 1~22 = common, 23~30 = 선택 × 3 단원으로 split 하여 4 ProblemSet 생성. `ProblemSet.title` 은 `YYYY학년도 수능 수학 (공통)` 형태",
"`backend/src/problem-sets/parsing/strategies/kice.ts` 및 관련 OCR 프롬프트에서 비수학 분기 제거. `shared.ts`·`ocr-fix.ts`·`seed.ts` 에서 비수학 과목 참조 삭제",
"`data/kice/{2025,2026}/{국어,영어,한국사,사회탐구}/` 삭제 + `.gitignore` 정리. `backend/uploads/problems/{year}/{비수학 과목}/` 도 삭제",
"Prisma: `model PsProblem { id Int, bojId Int @unique, title, level Int, tags Json, acceptedUserCount Int?, averageTries Float?, updatedAt }`, `model PsBookmark { userId, psProblemId, createdAt @@unique([userId,psProblemId]) }`, `StudyLog.psProblemId Int?` FK 추가. migration `phase10_ps_tables`",
"`backend/src/ps/solved-ac.client.ts` — axios 인스턴스(`https://solved.ac/api/v3`, User-Agent 명시, 타임아웃 10s, 지수 백오프 3회). 메서드: `searchProblems(query, page)`, `getProblem(bojId)`, `getUser(handle)`, `getUserSolvedProblems(handle, page)`. 내부 rate-limit (초당 3req)",
"`PsModule` + `PsService` + `PsController` — endpoints: `GET /api/ps/search?q=&level=&page=`, `GET /api/ps/problems/:bojId`, `POST /api/ps/bookmarks { bojId }`, `DELETE /api/ps/bookmarks/:bojId`, `GET /api/ps/bookmarks`, `POST /api/ps/sync { bojHandle? }` (핸들 없으면 `User.bojHandle` 사용). JWT guard + throttler",
"Sync 멱등성 저장소: `PsSyncState { userId @unique, lastSyncedAt, lastSolvedCount }` 추가해 마지막 sync 이후 새로 맞힌 문제만 가져옴. 초기 sync 는 `top_100` 기준 상위 100개로 제한(과도한 StudyLog 폭주 방지)",
"`frontend/src/app/onboarding/page.tsx` — GoalType 옵션 라벨/설명 교체. `math-suneung` 선택 시 4 단원 체크박스 그룹 노출, `ps` 선택 시 `bojHandle` 인풋(정규식 검증 `^[a-zA-Z0-9_-]{3,20}$`) 노출",
"`frontend/src/lib/api.ts` + `MeUser` 타입: `bojHandle`, `focusUnits: MathUnit[]`, `GoalType` 유니언 갱신. `api/me` PATCH payload 확장",
"전역 과목 리스트 정리 — `subjects/page.tsx`, `exams/page.tsx`, `review/page.tsx`, `review/history/page.tsx`, `dashboard`, `school/*` 에서 비수학 하드코딩 참조 제거. `exam/shared.ts::getExamDurationSeconds` → 수학 100분 고정. `SideNav` 에서 국어/영어/한국사 링크 제거",
"`frontend/src/app/ps/` 신규 섹션 — `page.tsx`(검색 + 필터 + 북마크 버튼), `bookmarks/page.tsx`(북마크 목록), `sync/page.tsx`(핸들 동기화 버튼 + 상태). BottomNav/SideNav 에 `PS` 진입점 추가",
"`landing`/`pricing`/`dashboard` 카피 업데이트 — \"수능 국어/영어…\" 문구를 \"수학 수능 + PS\" 로 교체, 아카이브 design HTML 과 docs 에서도 혼동 없도록 정리",
"로컬 커밋 + push → Gitea `hanarang/reloop-v2 master`. commit 단위는 10A / 10B / 10C 별로 나눔",
"`ssh dev@10.10.10.169` → `cd ~/reloop-v2 && git pull`. backend 에서 `mysqldump reloop_v2 > backups/phase10-pre.sql` 실행 후 `pnpm --filter backend exec prisma migrate deploy` → `pnpm --filter backend build`",
"비수학 wipe 스크립트 운영 실행 — `node backend/dist/scripts/wipe-non-math.js --apply`. 이어서 `pnpm cli:kice-import --year=2025 && --year=2026` 로 수학 8 ProblemSet 재생성",
"Frontend 빌드 + standalone copy + `pm2 reload reloop-web reloop-api --update-env`",
"스모크 테스트 — 온보딩 `math-suneung` 플로우 + 단원 선택 저장, `ps` 플로우 + 더미 BOJ 핸들 sync, `/ps/search` 검색, `/exams` 에 수학 4단원 표시, `/review` 에 비수학 흔적 없음"
],
"recentEdits": [
".claude/sessions/.last_inbox_check",
".claude/state/plans-state.json",
".claude/state/tool-failure-counter.txt",
".claude/logs/subagent-history.jsonl",
".claude/state/breezing-timeline.jsonl",
".claude/state/compaction-events.jsonl",
".claude/state/contracts/7A.1.sprint-contract.json",
".claude/state/contracts/7A.2.sprint-contract.json",
".claude/state/contracts/7A.3.sprint-contract.json",
".claude/state/contracts/7A.4.sprint-contract.json",
".claude/state/contracts/7A.5.sprint-contract.json",
".claude/state/contracts/7A.6.sprint-contract.json",
".claude/state/contracts/7A.7.sprint-contract.json",
".claude/state/contracts/7A.8.sprint-contract.json",
".claude/state/contracts/7D.1.sprint-contract.json",
".claude/state/handoff-artifact.json",
".claude/state/phase7d-base-ref",
".claude/state/precompact-snapshot.json",
".claude/state/task-quality-gate.json",
".claude/state/worktree-info.json"
],
"metrics": null
}

View File

@@ -0,0 +1 @@
BASE_REF=9c3efeb4641856a668bf836f0c50575422d6eb84

View File

@@ -1,7 +1,7 @@
{
"timestamp": "2026-04-11T14:58:57Z",
"timestamp": "2026-04-12T07:09:23Z",
"pm_pending": 0,
"cc_todo": 0,
"cc_todo": 8,
"cc_wip": 0,
"cc_done": 0,
"pm_confirmed": 0

View File

@@ -0,0 +1,464 @@
{
"version": "1.0.0",
"legacy_version": "1.0.0",
"artifactType": "precompact-snapshot",
"timestamp": "2026-04-17T00:55:24.687Z",
"sessionId": "",
"previous_state": {
"summary": "Before compaction: 20 recent edit(s)",
"session_state": null,
"plan_counts": {
"total": 24,
"wip": 0,
"blocked": 0,
"recent_edits": 20
}
},
"next_action": {
"taskId": "7B.1",
"task": "Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)",
"dod": "migration 통과, User/ProblemSet 양방향 relation 정의됨",
"depends": "7A.1",
"status": "cc:TODO",
"source": "Plans.md",
"priority": "normal",
"summary": "Continue 7B.1 Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)"
},
"open_risks": [
{
"severity": "medium",
"kind": "verification",
"summary": "20 recent edit(s) should be re-validated after resume",
"detail": ".claude/sessions/.last_inbox_check, .claude/state/plans-state.json, .claude/state/tool-failure-counter.txt, .claude/logs/subagent-history.jsonl, .claude/state/breezing-timeline.jsonl"
}
],
"failed_checks": [],
"decision_log": [
{
"timestamp": "2026-04-17T00:55:24.687Z",
"actor": "pre-compact-save",
"decision": "canonical_handoff_artifact_written",
"rationale": "Persist a stable JSON artifact in .claude/state for long-running session handoff."
},
{
"timestamp": "2026-04-17T00:55:24.687Z",
"actor": "pre-compact-save",
"decision": "legacy_snapshot_mirrored",
"rationale": "Keep precompact-snapshot.json for backward compatibility with older hooks."
},
{
"timestamp": "2026-04-17T00:55:24.687Z",
"actor": "pre-compact-save",
"decision": "next_action_selected",
"rationale": "Continue 7B.1 Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`) (source: Plans.md)"
}
],
"context_reset": {
"policy": {
"mode": "auto",
"dryRun": false,
"thresholds": {
"wipTasks": 4,
"blockedTasks": 1,
"recentEdits": 8,
"failedChecks": 1,
"sessionAgeMinutes": 120
}
},
"recommended": true,
"summary": "Context reset recommended (auto): 20 recent edit count exceed threshold 8",
"reasons": [
"20 recent edit count exceed threshold 8"
],
"candidates": [
{
"key": "wip_tasks",
"label": "WIP task count",
"actual": 0,
"threshold": 4,
"triggered": false
},
{
"key": "blocked_tasks",
"label": "blocked task count",
"actual": 0,
"threshold": 1,
"triggered": false
},
{
"key": "recent_edits",
"label": "recent edit count",
"actual": 20,
"threshold": 8,
"triggered": true
},
{
"key": "failed_checks",
"label": "failed check count",
"actual": 0,
"threshold": 1,
"triggered": false
},
{
"key": "session_age_minutes",
"label": "session age (minutes)",
"actual": 0,
"threshold": 120,
"triggered": false
}
],
"counters": {
"wipTasks": 0,
"blockedTasks": 0,
"recentEdits": 20,
"failedChecks": 0,
"sessionAgeMinutes": null
}
},
"continuity": {
"plugin_first_workflow": true,
"resume_aware_effort_continuity": true,
"effort_hint": "medium",
"active_skill": null,
"summary": "plugin-first workflow: enabled; resume-aware effort continuity: medium; next_task=7B.1"
},
"planItems": [
{
"taskId": "7B.1",
"title": "Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)",
"dod": "migration 통과, User/ProblemSet 양방향 relation 정의됨",
"depends": "7A.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.2",
"title": "backend `me/problem-sets` module: `GET /api/me/problem-sets` (내 추가분 list), `POST /api/me/problem-sets` (`{ problemSetId }`), `DELETE /api/me/problem-sets/:id` (soft archive)",
"dod": "데모 계정으로 3개 API 모두 200/201/204 응답. 중복 추가 시 409",
"depends": "7B.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.3",
"title": "frontend `lib/api.ts` 에 `UserProblemSet`, `addProblemSet`, `listMyProblemSets`, `removeProblemSet` 타입/헬퍼 추가",
"dod": "타입 에러 없이 컴파일, stats 페이지에서 호출 가능",
"depends": "7B.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.4",
"title": "frontend `/study` 페이지 탭 재설계: [내 문제집 / 카탈로그 / 직접 입력] 3 탭. \"카탈로그\" 에 전체 ProblemSet 목록 + 각 카드에 \"+ 내 목록에 추가\" 버튼. \"내 문제집\" 에 추가한 것만 표시",
"dod": "카탈로그에서 카드 클릭 → 내 목록 탭에 즉시 반영. 중복 클릭 방지",
"depends": "7B.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "7B.5",
"title": "\"내 문제집\" 탭 상세: 선택한 ProblemSet → 문항 리스트 → 각 문항 \"풀기\" 버튼 → 기존 `/study/form?problemId=N` 으로 이동해 결과 입력",
"dod": "데모 계정에서 2026 수학 → 15번 클릭 → 결과 기록 → 복습 큐 진입까지 전 플로우 통과",
"depends": "7B.4",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.1",
"title": "Prisma 스키마: `MathUnit` enum, `ProblemSet.mathUnit MathUnit?`, `User.bojHandle String? @db.VarChar(32)`, `User.focusUnits Json?`. migration `phase10_math_units_and_ps` 생성",
"dod": "`prisma migrate dev --name phase10_math_units_and_ps` 성공, `@prisma/client` 재생성, 기존 행 NULL 기본값",
"depends": "-",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.2",
"title": "비수학 데이터 wipe 스크립트 `backend/scripts/wipe-non-math.ts` — `ProblemSet where subjectName != '수학'` 연쇄 삭제(Assignment/AssignmentSubmission, StudyLog, ReviewSchedule, Problem, Passage 포함). 실행 전 mysqldump 백업을 `backups/` 에 저장",
"dod": "dry-run 시 삭제 대상 수 출력, `--apply` 시 실제 삭제 후 `ProblemSet count where subjectName='수학'` 만 남음",
"depends": "10A.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.3",
"title": "`kice-import.ts` 리팩터: `TARGET_SUBJECTS = ['수학']`, 30문항을 1~22 = common, 23~30 = 선택 × 3 단원으로 split 하여 4 ProblemSet 생성. `ProblemSet.title` 은 `YYYY학년도 수능 수학 (공통)` 형태",
"dod": "`pnpm cli:kice-import --dry-run --year=2026` 시 common 22 + 확통 8 + 미적 8 + 기하 8 = 46 문항 리포트",
"depends": "10A.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.4",
"title": "`backend/src/problem-sets/parsing/strategies/kice.ts` 및 관련 OCR 프롬프트에서 비수학 분기 제거. `shared.ts`·`ocr-fix.ts`·`seed.ts` 에서 비수학 과목 참조 삭제",
"dod": "`grep -rE \"국어|영어|한국사|생활과 윤리|사회탐구\" backend/src backend/scripts` 결과 0",
"depends": "10A.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10A.5",
"title": "`data/kice/{2025,2026}/{국어,영어,한국사,사회탐구}/` 삭제 + `.gitignore` 정리. `backend/uploads/problems/{year}/{비수학 과목}/` 도 삭제",
"dod": "디스크에 수학 PDF + 수학 이미지 crop 만 남음",
"depends": "10A.4",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.1",
"title": "Prisma: `model PsProblem { id Int, bojId Int @unique, title, level Int, tags Json, acceptedUserCount Int?, averageTries Float?, updatedAt }`, `model PsBookmark { userId, psProblemId, createdAt @@unique([userId,psProblemId]) }`, `StudyLog.psProblemId Int?` FK 추가. migration `phase10_ps_tables`",
"dod": "`prisma migrate dev` 성공, 관계 매핑 검증",
"depends": "10A.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.2",
"title": "`backend/src/ps/solved-ac.client.ts` — axios 인스턴스(`https://solved.ac/api/v3`, User-Agent 명시, 타임아웃 10s, 지수 백오프 3회). 메서드: `searchProblems(query, page)`, `getProblem(bojId)`, `getUser(handle)`, `getUserSolvedProblems(handle, page)`. 내부 rate-limit (초당 3req)",
"dod": "단위 테스트: mocked axios 로 4 메서드 응답 파싱 정상",
"depends": "10B.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.3",
"title": "`PsModule` + `PsService` + `PsController` — endpoints: `GET /api/ps/search?q=&level=&page=`, `GET /api/ps/problems/:bojId`, `POST /api/ps/bookmarks { bojId }`, `DELETE /api/ps/bookmarks/:bojId`, `GET /api/ps/bookmarks`, `POST /api/ps/sync { bojHandle? }` (핸들 없으면 `User.bojHandle` 사용). JWT guard + throttler",
"dod": "Postman/curl 6 endpoint 200, sync 실행 시 PsProblem upsert + 시스템 Subject \"PS\" 생성 + StudyLog bulk insert, 멱등성 확인(중복 sync 시 같은 BOJ 문제 StudyLog 추가 생성 안 함)",
"depends": "10B.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10B.4",
"title": "Sync 멱등성 저장소: `PsSyncState { userId @unique, lastSyncedAt, lastSolvedCount }` 추가해 마지막 sync 이후 새로 맞힌 문제만 가져옴. 초기 sync 는 `top_100` 기준 상위 100개로 제한(과도한 StudyLog 폭주 방지)",
"dod": "1차 sync 후 `PsSyncState.lastSolvedCount` 저장, 2차 sync 는 차분만 insert",
"depends": "10B.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.1",
"title": "`frontend/src/app/onboarding/page.tsx` — GoalType 옵션 라벨/설명 교체. `math-suneung` 선택 시 4 단원 체크박스 그룹 노출, `ps` 선택 시 `bojHandle` 인풋(정규식 검증 `^[a-zA-Z0-9_-]{3,20}$`) 노출",
"dod": "각 GoalType 분기 UI 정상, 저장 후 `/auth/me` 응답에 `focusUnits` 또는 `bojHandle` 반영",
"depends": "10B.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.2",
"title": "`frontend/src/lib/api.ts` + `MeUser` 타입: `bojHandle`, `focusUnits: MathUnit[]`, `GoalType` 유니언 갱신. `api/me` PATCH payload 확장",
"dod": "타입 에러 0, API PATCH 200",
"depends": "10C.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.3",
"title": "전역 과목 리스트 정리 — `subjects/page.tsx`, `exams/page.tsx`, `review/page.tsx`, `review/history/page.tsx`, `dashboard`, `school/*` 에서 비수학 하드코딩 참조 제거. `exam/shared.ts::getExamDurationSeconds` → 수학 100분 고정. `SideNav` 에서 국어/영어/한국사 링크 제거",
"dod": "`grep -rE \"국어|영어|한국사|생활과 윤리\" frontend/src` = 0",
"depends": "10C.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.4",
"title": "`frontend/src/app/ps/` 신규 섹션 — `page.tsx`(검색 + 필터 + 북마크 버튼), `bookmarks/page.tsx`(북마크 목록), `sync/page.tsx`(핸들 동기화 버튼 + 상태). BottomNav/SideNav 에 `PS` 진입점 추가",
"dod": "4 경로 200 + search/bookmark/sync e2e 수동 검증 통과",
"depends": "10C.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10C.5",
"title": "`landing`/`pricing`/`dashboard` 카피 업데이트 — \"수능 국어/영어…\" 문구를 \"수학 수능 + PS\" 로 교체, 아카이브 design HTML 과 docs 에서도 혼동 없도록 정리",
"dod": "`grep -rE \"국어|영어|한국사|생활과 윤리\" frontend/src docs README.md` = 0 (아카이브 `.claude/archive/` 는 제외)",
"depends": "10C.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.1",
"title": "로컬 커밋 + push → Gitea `hanarang/reloop-v2 master`. commit 단위는 10A / 10B / 10C 별로 나눔",
"dod": "`git push` 성공, `tea pr list` 없이 master 직접 반영",
"depends": "10C.5",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.2",
"title": "`ssh dev@10.10.10.169` → `cd ~/reloop-v2 && git pull`. backend 에서 `mysqldump reloop_v2 > backups/phase10-pre.sql` 실행 후 `pnpm --filter backend exec prisma migrate deploy` → `pnpm --filter backend build`",
"dod": "마이그레이션 적용됨, dist/main.js 빌드 성공, DB 백업 존재",
"depends": "10D.1",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.3",
"title": "비수학 wipe 스크립트 운영 실행 — `node backend/dist/scripts/wipe-non-math.js --apply`. 이어서 `pnpm cli:kice-import --year=2025 && --year=2026` 로 수학 8 ProblemSet 재생성",
"dod": "DB 에 수학 8 ProblemSet / 92 problems, 비수학 0",
"depends": "10D.2",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.4",
"title": "Frontend 빌드 + standalone copy + `pm2 reload reloop-web reloop-api --update-env`",
"dod": "PM2 status online, `curl https://reloop-api.nabomhalang.co.kr/health` 200, `curl https://reloop.nabomhalang.co.kr` 200",
"depends": "10D.3",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
},
{
"taskId": "10D.5",
"title": "스모크 테스트 — 온보딩 `math-suneung` 플로우 + 단원 선택 저장, `ps` 플로우 + 더미 BOJ 핸들 sync, `/ps/search` 검색, `/exams` 에 수학 4단원 표시, `/review` 에 비수학 흔적 없음",
"dod": "5 시나리오 수동 통과 스크린샷 또는 로그 캡처",
"depends": "10D.4",
"status": "cc:TODO",
"tags": {
"todo": true,
"wip": false,
"blocked": false
}
}
],
"wipTasks": [
"Prisma: `UserProblemSet` 모델 (`userId`, `problemSetId`, `addedAt`, `archivedAt DateTime?`, `@@unique([userId, problemSetId])`, `@@index([userId])`)",
"backend `me/problem-sets` module: `GET /api/me/problem-sets` (내 추가분 list), `POST /api/me/problem-sets` (`{ problemSetId }`), `DELETE /api/me/problem-sets/:id` (soft archive)",
"frontend `lib/api.ts` 에 `UserProblemSet`, `addProblemSet`, `listMyProblemSets`, `removeProblemSet` 타입/헬퍼 추가",
"frontend `/study` 페이지 탭 재설계: [내 문제집 / 카탈로그 / 직접 입력] 3 탭. \"카탈로그\" 에 전체 ProblemSet 목록 + 각 카드에 \"+ 내 목록에 추가\" 버튼. \"내 문제집\" 에 추가한 것만 표시",
"\"내 문제집\" 탭 상세: 선택한 ProblemSet → 문항 리스트 → 각 문항 \"풀기\" 버튼 → 기존 `/study/form?problemId=N` 으로 이동해 결과 입력",
"Prisma 스키마: `MathUnit` enum, `ProblemSet.mathUnit MathUnit?`, `User.bojHandle String? @db.VarChar(32)`, `User.focusUnits Json?`. migration `phase10_math_units_and_ps` 생성",
"비수학 데이터 wipe 스크립트 `backend/scripts/wipe-non-math.ts` — `ProblemSet where subjectName != '수학'` 연쇄 삭제(Assignment/AssignmentSubmission, StudyLog, ReviewSchedule, Problem, Passage 포함). 실행 전 mysqldump 백업을 `backups/` 에 저장",
"`kice-import.ts` 리팩터: `TARGET_SUBJECTS = ['수학']`, 30문항을 1~22 = common, 23~30 = 선택 × 3 단원으로 split 하여 4 ProblemSet 생성. `ProblemSet.title` 은 `YYYY학년도 수능 수학 (공통)` 형태",
"`backend/src/problem-sets/parsing/strategies/kice.ts` 및 관련 OCR 프롬프트에서 비수학 분기 제거. `shared.ts`·`ocr-fix.ts`·`seed.ts` 에서 비수학 과목 참조 삭제",
"`data/kice/{2025,2026}/{국어,영어,한국사,사회탐구}/` 삭제 + `.gitignore` 정리. `backend/uploads/problems/{year}/{비수학 과목}/` 도 삭제",
"Prisma: `model PsProblem { id Int, bojId Int @unique, title, level Int, tags Json, acceptedUserCount Int?, averageTries Float?, updatedAt }`, `model PsBookmark { userId, psProblemId, createdAt @@unique([userId,psProblemId]) }`, `StudyLog.psProblemId Int?` FK 추가. migration `phase10_ps_tables`",
"`backend/src/ps/solved-ac.client.ts` — axios 인스턴스(`https://solved.ac/api/v3`, User-Agent 명시, 타임아웃 10s, 지수 백오프 3회). 메서드: `searchProblems(query, page)`, `getProblem(bojId)`, `getUser(handle)`, `getUserSolvedProblems(handle, page)`. 내부 rate-limit (초당 3req)",
"`PsModule` + `PsService` + `PsController` — endpoints: `GET /api/ps/search?q=&level=&page=`, `GET /api/ps/problems/:bojId`, `POST /api/ps/bookmarks { bojId }`, `DELETE /api/ps/bookmarks/:bojId`, `GET /api/ps/bookmarks`, `POST /api/ps/sync { bojHandle? }` (핸들 없으면 `User.bojHandle` 사용). JWT guard + throttler",
"Sync 멱등성 저장소: `PsSyncState { userId @unique, lastSyncedAt, lastSolvedCount }` 추가해 마지막 sync 이후 새로 맞힌 문제만 가져옴. 초기 sync 는 `top_100` 기준 상위 100개로 제한(과도한 StudyLog 폭주 방지)",
"`frontend/src/app/onboarding/page.tsx` — GoalType 옵션 라벨/설명 교체. `math-suneung` 선택 시 4 단원 체크박스 그룹 노출, `ps` 선택 시 `bojHandle` 인풋(정규식 검증 `^[a-zA-Z0-9_-]{3,20}$`) 노출",
"`frontend/src/lib/api.ts` + `MeUser` 타입: `bojHandle`, `focusUnits: MathUnit[]`, `GoalType` 유니언 갱신. `api/me` PATCH payload 확장",
"전역 과목 리스트 정리 — `subjects/page.tsx`, `exams/page.tsx`, `review/page.tsx`, `review/history/page.tsx`, `dashboard`, `school/*` 에서 비수학 하드코딩 참조 제거. `exam/shared.ts::getExamDurationSeconds` → 수학 100분 고정. `SideNav` 에서 국어/영어/한국사 링크 제거",
"`frontend/src/app/ps/` 신규 섹션 — `page.tsx`(검색 + 필터 + 북마크 버튼), `bookmarks/page.tsx`(북마크 목록), `sync/page.tsx`(핸들 동기화 버튼 + 상태). BottomNav/SideNav 에 `PS` 진입점 추가",
"`landing`/`pricing`/`dashboard` 카피 업데이트 — \"수능 국어/영어…\" 문구를 \"수학 수능 + PS\" 로 교체, 아카이브 design HTML 과 docs 에서도 혼동 없도록 정리",
"로컬 커밋 + push → Gitea `hanarang/reloop-v2 master`. commit 단위는 10A / 10B / 10C 별로 나눔",
"`ssh dev@10.10.10.169` → `cd ~/reloop-v2 && git pull`. backend 에서 `mysqldump reloop_v2 > backups/phase10-pre.sql` 실행 후 `pnpm --filter backend exec prisma migrate deploy` → `pnpm --filter backend build`",
"비수학 wipe 스크립트 운영 실행 — `node backend/dist/scripts/wipe-non-math.js --apply`. 이어서 `pnpm cli:kice-import --year=2025 && --year=2026` 로 수학 8 ProblemSet 재생성",
"Frontend 빌드 + standalone copy + `pm2 reload reloop-web reloop-api --update-env`",
"스모크 테스트 — 온보딩 `math-suneung` 플로우 + 단원 선택 저장, `ps` 플로우 + 더미 BOJ 핸들 sync, `/ps/search` 검색, `/exams` 에 수학 4단원 표시, `/review` 에 비수학 흔적 없음"
],
"recentEdits": [
".claude/sessions/.last_inbox_check",
".claude/state/plans-state.json",
".claude/state/tool-failure-counter.txt",
".claude/logs/subagent-history.jsonl",
".claude/state/breezing-timeline.jsonl",
".claude/state/compaction-events.jsonl",
".claude/state/contracts/7A.1.sprint-contract.json",
".claude/state/contracts/7A.2.sprint-contract.json",
".claude/state/contracts/7A.3.sprint-contract.json",
".claude/state/contracts/7A.4.sprint-contract.json",
".claude/state/contracts/7A.5.sprint-contract.json",
".claude/state/contracts/7A.6.sprint-contract.json",
".claude/state/contracts/7A.7.sprint-contract.json",
".claude/state/contracts/7A.8.sprint-contract.json",
".claude/state/contracts/7D.1.sprint-contract.json",
".claude/state/handoff-artifact.json",
".claude/state/phase7d-base-ref",
".claude/state/precompact-snapshot.json",
".claude/state/task-quality-gate.json",
".claude/state/worktree-info.json"
],
"metrics": null
}

View File

@@ -0,0 +1,152 @@
{
"39": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:22:01Z"
},
"40": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:22:03Z"
},
"41": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:22:03Z"
},
"42": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:22:03Z"
},
"43": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:22:04Z"
},
"44": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:22:04Z"
},
"45": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:11Z"
},
"46": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:11Z"
},
"47": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:12Z"
},
"48": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:12Z"
},
"49": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:13Z"
},
"50": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:13Z"
},
"38": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T15:45:13Z"
},
"51": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T16:36:04Z"
},
"52": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T16:42:51Z"
},
"53": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T16:44:00Z"
},
"54": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T16:45:12Z"
},
"55": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T16:46:25Z"
},
"56": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T16:47:35Z"
},
"57": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T18:24:27Z"
},
"58": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T18:46:47Z"
},
"59": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T20:22:38Z"
},
"61": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T21:30:52Z"
},
"1": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-16T23:50:14Z"
},
"2": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-16T23:50:14Z"
},
"3": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-16T23:50:15Z"
},
"4": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-16T23:52:08Z"
},
"9": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T00:34:53Z"
},
"10": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T00:34:53Z"
},
"11": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T01:03:58Z"
}
}

View File

@@ -1 +1 @@
1 1775919057
1 1776387989

View File

@@ -0,0 +1 @@
{"worker_id": "d97749a0-5ebc-42a9-b387-b9abff64fd38", "created_at": "2026-04-11T16:46:46Z", "cwd": "/home/erang/reloop-v2"}

View File

@@ -0,0 +1 @@
{"event":"subagent_stop","timestamp":"2026-04-11T18:44:34Z","agent_id":"unknown","transcript_path":"","transcript_preview":""}

View File

@@ -0,0 +1 @@
1776388140

View File

@@ -0,0 +1,9 @@
{"event": "task_completed", "teammate": "", "task_id": "60", "subject": "7F.4 — Parser refactor + segmentation fix", "description": "Extract parsing to backend module, fix page header/footer stripping, fix problem boundary detection,", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T21:16:18Z"}
{"event": "task_completed", "teammate": "", "task_id": "62", "subject": "Phase 7G/7H/8 — Feedback revision + 수학 + production", "description": "11 tasks: 7G.1-7G.6 (IA, exam UX, log detail, landing, themed forms, profile), 7H.1-7H.2 (수학 data + ", "agent_id": "", "agent_type": "", "timestamp": "2026-04-11T23:31:04Z"}
{"event": "task_completed", "teammate": "", "task_id": "5", "subject": "10. 프로필 학습설정에 복습 알고리즘 설명 추가", "description": "페르소나+학습강도 선택 아래에 현재 설정 기반 복습 알고리즘 동작 설명 텍스트 표시", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T00:13:31Z"}
{"event": "task_completed", "teammate": "", "task_id": "6", "subject": "11. 시험목표/집중단원 섹션 삭제", "description": "프로필 학습설정에서 시험목표/집중단원 카드 제거", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T00:13:31Z"}
{"event": "task_completed", "teammate": "", "task_id": "8", "subject": "13. PDF 문제 분리 OCR 버그 수정", "description": "2026년 3월 수학 모의고사 PDF 업로드 시 \"인식된 문제가 없다\" — parsePdfToProblems 디버깅", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T00:13:32Z"}
{"event": "task_completed", "teammate": "", "task_id": "7", "subject": "12. README.md 다시 작성", "description": "프로젝트 README.md를 깔끔하게 재작성", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T00:14:36Z"}
{"event": "task_completed", "teammate": "", "task_id": "12", "subject": "PDF→문제집: 스키마 마이그레이션", "description": "ProblemSet에 sourceType, uploadedByUserId 추가. Problem 기존 필드 활용. Prisma migrate dev", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T01:08:20Z"}
{"event": "task_completed", "teammate": "", "task_id": "13", "subject": "PDF→문제집: 백엔드 API 변경", "description": "import-from-pdf → ProblemSet+Problem 생성으로 변경. problem-sets.service에 createFromPdf 메서드 추가. 기존 from-pr", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T01:09:39Z"}
{"event": "task_completed", "teammate": "", "task_id": "15", "subject": "문제 풀이 UI 통일: 공통 컴포넌트 설계", "description": "모든 문제 풀이 화면에 공통 기능: 문제이미지, 필기모드(캔버스), 정답확인, 난이도3단계(H/M/E), 메모, 복습큐 배정. ProblemSolver 공통 컴포넌트 또는 통일된 ", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T01:16:43Z"}

View File

@@ -0,0 +1 @@
{"event": "post_compact", "has_wip": "false", "has_snapshot": "false", "has_handoff": "false", "timestamp": "2026-04-11T18:44:35Z"}

View File

View File

@@ -0,0 +1,47 @@
{
"60": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T21:16:18Z"
},
"62": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-11T23:31:04Z"
},
"5": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T00:13:31Z"
},
"6": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T00:13:31Z"
},
"8": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T00:13:32Z"
},
"7": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T00:14:36Z"
},
"12": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T01:08:20Z"
},
"13": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T01:09:39Z"
},
"15": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T01:16:43Z"
}
}

View File

@@ -0,0 +1,7 @@
{
"timestamp": "2026-04-17T01:12:39Z",
"changed_file": "/home/erang/reloop-v2/frontend/src/app/exams/page.tsx",
"test_command": "npm test",
"related_test": "",
"recommendation": "テストの実行を推奨します"
}

View File

@@ -0,0 +1 @@
0 0

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE `problem_sets` ADD COLUMN `sourceType` VARCHAR(191) NOT NULL DEFAULT 'kice',
ADD COLUMN `uploadedByUserId` INTEGER NULL;
-- CreateIndex
CREATE INDEX `problem_sets_uploadedByUserId_idx` ON `problem_sets`(`uploadedByUserId`);
-- AddForeignKey
ALTER TABLE `problem_sets` ADD CONSTRAINT `problem_sets_uploadedByUserId_fkey` FOREIGN KEY (`uploadedByUserId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -105,6 +105,7 @@ model User {
classMemberships ClassMember[]
teacherClasses Class[] @relation("teacherClasses")
assignmentSubmissions AssignmentSubmission[]
uploadedProblemSets ProblemSet[] @relation("uploadedProblemSets")
@@map("users")
}
@@ -201,7 +202,7 @@ model ReviewSchedule {
model ProblemSet {
id Int @id @default(autoincrement())
title String
/// examType 허용값: "sat" | "mock-june" | "mock-sept" | "academy"
/// examType 허용값: "sat" | "mock-june" | "mock-sept" | "academy" | "user-upload"
examType String
year Int
subjectName String
@@ -210,6 +211,11 @@ model ProblemSet {
/// 듣기 음원 파일 경로 배열 (data/kice/ 상대경로).
/// 예: ["2026/listening/audios/01_track.mp3", ...]
audioUrls Json?
/// "kice" | "user-upload" — 기출 vs 사용자 업로드 구분
sourceType String @default("kice")
/// 사용자 업로드인 경우 업로드한 사용자
uploadedByUserId Int?
uploadedBy User? @relation("uploadedProblemSets", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
problems Problem[]
@@ -217,6 +223,7 @@ model ProblemSet {
assignments Assignment[]
@@unique([year, examType, subjectName, mathUnit])
@@index([uploadedByUserId])
@@map("problem_sets")
}

View File

@@ -1,14 +1,28 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import {
IsArray,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { ProblemSetsService } from './problem-sets.service';
class ListProblemSetsQuery {
@@ -24,8 +38,41 @@ class ListProblemSetsQuery {
year?: number;
}
// TODO(phase7): Pro tier gating — problem-sets 는 Pro 플랜 전용으로 광고됨.
// 결제 연동 시 TierGuard 또는 각 @Get 에 인라인 체크.
class CreateFromPdfProblemDto {
@IsInt()
number: number;
@IsString()
imageUrl: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5)
correctAnswer?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
difficulty?: number;
}
class CreateFromPdfDto {
@IsString()
title: string;
@IsOptional()
@IsString()
subjectName?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateFromPdfProblemDto)
problems: CreateFromPdfProblemDto[];
}
@Controller('problem-sets')
@UseGuards(JwtAuthGuard)
export class ProblemSetsController {
@@ -36,8 +83,29 @@ export class ProblemSetsController {
return this.svc.list(q);
}
@Get('my-uploads')
myUploads(@CurrentUser() user: AuthUser) {
return this.svc.listMyUploads(user.id);
}
@Get(':id')
one(@Param('id', ParseIntPipe) id: number) {
return this.svc.getOne(id);
}
@Post('create-from-pdf')
createFromPdf(
@CurrentUser() user: AuthUser,
@Body() dto: CreateFromPdfDto,
) {
return this.svc.createFromPdf(user.id, dto);
}
@Delete(':id')
deleteUpload(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.svc.deleteUpload(user.id, id);
}
}

View File

@@ -1,15 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ProblemSetsService {
constructor(private readonly prisma: PrismaService) {}
/** KICE 기출 문제집 목록 (전체 공유) */
list(opts: { subjectName?: string; year?: number }) {
// 프로토타입: 수학 ProblemSet만 노출 (soft filter — DB 데이터는 유지)
return this.prisma.problemSet.findMany({
where: {
subjectName: '수학',
sourceType: 'kice',
...(opts.year && { year: opts.year }),
},
include: {
@@ -20,6 +21,20 @@ export class ProblemSetsService {
});
}
/** 내가 업로드한 문제집 목록 */
listMyUploads(userId: number) {
return this.prisma.problemSet.findMany({
where: {
sourceType: 'user-upload',
uploadedByUserId: userId,
},
include: {
_count: { select: { problems: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async getOne(id: number) {
const ps = await this.prisma.problemSet.findUnique({
where: { id },
@@ -31,4 +46,64 @@ export class ProblemSetsService {
if (!ps) throw new NotFoundException();
return ps;
}
/** PDF 크롭 결과를 ProblemSet + Problem 레코드로 저장 */
async createFromPdf(
userId: number,
input: {
title: string;
subjectName?: string;
problems: Array<{
number: number;
imageUrl: string;
correctAnswer?: number | null;
difficulty?: number;
}>;
},
) {
return this.prisma.$transaction(async (tx) => {
const ps = await tx.problemSet.create({
data: {
title: input.title,
examType: 'user-upload',
year: new Date().getFullYear(),
subjectName: input.subjectName || '수학',
sourceType: 'user-upload',
uploadedByUserId: userId,
},
});
const problems = await Promise.all(
input.problems.map((p) =>
tx.problem.create({
data: {
problemSetId: ps.id,
number: p.number,
title: `${p.number}`,
difficulty: p.difficulty ?? 0.5,
imageUrl: p.imageUrl,
answerNumber: p.correctAnswer ?? null,
needsReview: false,
},
}),
),
);
return {
problemSet: { ...ps, _count: { problems: problems.length } },
problems,
};
});
}
/** 사용자 업로드 문제집 삭제 (본인만 가능) */
async deleteUpload(userId: number, id: number) {
const ps = await this.prisma.problemSet.findUnique({ where: { id } });
if (!ps) throw new NotFoundException();
if (ps.sourceType !== 'user-upload' || ps.uploadedByUserId !== userId) {
throw new ForbiddenException();
}
await this.prisma.problemSet.delete({ where: { id } });
return { deleted: true };
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
1776388416

View File

@@ -0,0 +1 @@
{"event": "task_completed", "teammate": "", "task_id": "14", "subject": "PDF→문제집: 프론트엔드 import-pdf 페이지 리워크", "description": "PDF 업로드 → 크롭 → 문제집으로 저장. 복습큐 직행 대신 문제집 생성. exams 페이지에 '내 문제집' 섹션 추가", "agent_id": "", "agent_type": "", "timestamp": "2026-04-17T01:13:06Z"}

View File

@@ -0,0 +1,7 @@
{
"14": {
"failure_count": 0,
"last_action": "reset",
"updated_at": "2026-04-17T01:13:06Z"
}
}

View File

@@ -0,0 +1 @@
1 1775933439

View File

@@ -428,10 +428,6 @@ function DashboardBody() {
<SectionDot />
<SectionTitle> </SectionTitle>
</SectionTitleRow>
<SectionLink href="/stats">
<Icon name="caret-right" weight="bold" size={12} />
</SectionLink>
</SectionHead>
<SectionBody>
@@ -1176,19 +1172,6 @@ const SectionTitle = styled.h2`
font-weight: 500;
`;
const SectionLink = styled(Link)`
display: inline-flex;
align-items: center;
gap: 4px;
color: ${theme.color.textMute};
font-size: 12px;
transition: color 0.15s ease;
&:hover {
color: ${theme.color.textSub};
}
`;
const SectionBody = styled.div`
display: flex;
flex-direction: column;

View File

@@ -8,7 +8,7 @@ import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card, Input, Label, Textarea, PageHeader, ErrorText, HelpText } from '@/components/ui/primitives';
import { useToast } from '@/components/ui/Toast';
import { theme } from '@/styles/theme';
import { api, API_ASSET_BASE_URL } from '@/lib/api';
import { api, API_ASSET_BASE_URL, getMyUploadedProblemSets, deleteProblemSet, type ProblemSetSummary } from '@/lib/api';
import {
EBOOK_LIST,
PREMIUM_EBOOK_MAP,
@@ -81,6 +81,11 @@ function ExamsBody() {
const [registeredEbooks, setRegisteredEbooks] = useState<string[]>(loadRegisteredEbooks);
const [uploadedPdfs, setUploadedPdfs] = useState<UploadedPdf[]>(loadUploadedPdfs);
const [hiddenEbooks, setHiddenEbooks] = useState<string[]>(loadHiddenEbooks);
const [myProblemSets, setMyProblemSets] = useState<ProblemSetSummary[]>([]);
React.useEffect(() => {
getMyUploadedProblemSets().then(setMyProblemSets).catch(() => {});
}, []);
// 모달 상태
const [showCodeModal, setShowCodeModal] = useState(false);
@@ -220,12 +225,41 @@ function ExamsBody() {
</Button>
<Button type="button" $variant="primary" $size="sm" onClick={() => router.push('/study/import-pdf')}>
<Icon name="file-pdf" size={14} />
PDF
PDF
</Button>
</ActionButtons>
</ActionCard>
</ActionRow>
{/* 내가 만든 문제집 (PDF 크롭) */}
{myProblemSets.length > 0 && (
<>
<SectionLabel>
<Icon name="file-pdf" size={14} />
</SectionLabel>
<Grid>
{myProblemSets.map((ps) => (
<MyProblemSetCard
key={ps.id}
ps={ps}
router={router}
onDelete={async (id) => {
if (!window.confirm('이 문제집을 삭제할까?')) return;
try {
await deleteProblemSet(id);
setMyProblemSets((prev) => prev.filter((p) => p.id !== id));
showToast({ message: '삭제됐어.', variant: 'success' });
} catch {
showToast({ message: '삭제 실패', variant: 'danger' });
}
}}
/>
))}
</Grid>
</>
)}
{/* 프리미엄 ebook 섹션 */}
{premiumEbooks.length > 0 && (
<>
@@ -704,6 +738,46 @@ function PremiumEbookCard({
);
}
function MyProblemSetCard({
ps,
router,
onDelete,
}: {
ps: ProblemSetSummary;
router: ReturnType<typeof useRouter>;
onDelete: (id: number) => void;
}) {
const count = ps._count?.problems ?? 0;
return (
<SampleCard>
<DeleteBtn type="button" onClick={() => onDelete(ps.id)} title="문제집 삭제">
<Icon name="x" size={13} />
</DeleteBtn>
<SampleCardHeader>
<CardTitle>{ps.title}</CardTitle>
<SampleBadge $variant="default">
<Icon name="file-pdf" size={11} />
PDF
</SampleBadge>
</SampleCardHeader>
<CardMeta>{count} · {ps.year}</CardMeta>
<CardFooter>
<InfoLine>
<Icon name="book-open-text" size={16} />
</InfoLine>
<Button
type="button"
$variant="primary"
onClick={() => router.push(`/exams/sample-ebook/${ps.id}`)}
>
</Button>
</CardFooter>
</SampleCard>
);
}
function PdfCard({ pdf, onDelete }: { pdf: UploadedPdf; onDelete: (url: string) => void }) {
const fullUrl = `${API_ASSET_BASE_URL}${pdf.url}`;
const sizeMb = (pdf.size / 1024 / 1024).toFixed(1);

View File

@@ -11,6 +11,7 @@ import {
PassageImageSection,
ProblemImageStage,
} from '@/components/exam/ProblemVisual';
import DrawingCanvas, { PEN_COLORS, type PenColor } from '@/components/ui/DrawingCanvas';
import { Icon } from '@/components/ui/Icon';
import { useToast } from '@/components/ui/Toast';
import { Button, KbdHint } from '@/components/ui/primitives';
@@ -96,6 +97,9 @@ export default function ReviewPage() {
const [focusMode, setFocusMode] = useState(false);
const [lightboxOpenFor, setLightboxOpenFor] = useState<number | null>(null);
const [answerRevealed, setAnswerRevealed] = useState<Record<number, boolean>>({});
const [drawMode, setDrawMode] = useState(false);
const [penColor, setPenColor] = useState<PenColor>('#ef4444');
const [clearSignal, setClearSignal] = useState(0);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -632,20 +636,61 @@ export default function ReviewPage() {
/>
)}
{currentProblemImageUrl && (
<PromptImageButton
type="button"
onClick={() => setLightboxOpenFor(currentItem.id)}
aria-label={`${currentProblem?.problemSet?.subjectName ?? '수학'} ${currentProblem?.number ?? ''} 이미지 크게 보기`}
>
<PromptImageStage
imageUrl={currentProblemImageUrl}
alt={`${currentProblem?.problemSet?.subjectName ?? '수학'} ${currentProblem?.number ?? ''}`}
/>
<PromptImageHint>
<Icon name="export" size={14} />
</PromptImageHint>
</PromptImageButton>
<ProblemImageBlock>
<DrawToolbar>
<DrawToggle
type="button"
$active={drawMode}
onClick={() => setDrawMode((p) => !p)}
title="필기 모드"
>
<Icon name="pencil-simple" size={16} weight={drawMode ? 'fill' : 'regular'} />
</DrawToggle>
{drawMode && (
<>
{PEN_COLORS.map((c) => (
<PenColorBtn
key={c.value}
type="button"
$color={c.value}
$active={penColor === c.value}
onClick={() => setPenColor(c.value)}
title={c.label}
/>
))}
<DrawToggle
type="button"
$active={false}
onClick={() => setClearSignal((s) => s + 1)}
title="필기 지우기"
>
<Icon name="eraser" size={16} />
</DrawToggle>
</>
)}
<DrawToggle
type="button"
$active={false}
onClick={() => setLightboxOpenFor(currentItem.id)}
title="크게 보기"
>
<Icon name="export" size={16} />
</DrawToggle>
</DrawToolbar>
<PromptImageWrap>
<PromptImageStage
imageUrl={currentProblemImageUrl}
alt={`${currentProblem?.problemSet?.subjectName ?? '수학'} ${currentProblem?.number ?? ''}`}
/>
<DrawingCanvas
storageKey={`reloop-review-draw-${currentItem.studyLogId}`}
visible={drawMode}
active={drawMode}
penColor={penColor}
clearSignal={clearSignal}
/>
</PromptImageWrap>
</ProblemImageBlock>
)}
</PromptBlock>
)}
@@ -1697,6 +1742,57 @@ const ReviewDeleteBtn = styled.button`
}
`;
const ProblemImageBlock = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const DrawToolbar = styled.div`
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
`;
const DrawToggle = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid ${({ $active }) => ($active ? 'rgba(129, 140, 248, 0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79, 70, 229, 0.15)' : 'rgba(255,255,255,0.03)')};
color: ${({ $active }) => ($active ? '#a5b4fc' : theme.color.textSub)};
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(79, 70, 229, 0.12);
color: ${theme.color.textBright};
}
`;
const PenColorBtn = styled.button<{ $color: string; $active: boolean }>`
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid ${({ $active }) => ($active ? '#fff' : 'transparent')};
background: ${({ $color }) => $color};
cursor: pointer;
box-shadow: ${({ $active }) => ($active ? '0 0 0 2px rgba(255,255,255,0.3)' : 'none')};
transition: box-shadow 0.15s;
`;
const PromptImageWrap = styled.div`
position: relative;
border-radius: ${theme.radius.lg};
overflow: hidden;
border: 1px solid ${theme.color.borderSoftAlpha};
background: #fff;
`;
const AnswerRevealBlock = styled.div`
display: flex;
align-items: center;

View File

@@ -13,6 +13,7 @@ import {
ShortAnswerInput,
isShortAnswerProblem,
} from '@/components/exam/ProblemVisual';
import DrawingCanvas, { PEN_COLORS, type PenColor } from '@/components/ui/DrawingCanvas';
import { Icon } from '@/components/ui/Icon';
import { Button, Card } from '@/components/ui/primitives';
import {
@@ -44,6 +45,11 @@ function SolveStudyLogBody() {
const [error, setError] = useState<string | null>(null);
const [passageCollapsed, setPassageCollapsed] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [drawMode, setDrawMode] = useState(false);
const [penColor, setPenColor] = useState<PenColor>('#ef4444');
const [clearSignal, setClearSignal] = useState(0);
const [memo, setMemo] = useState('');
const [memoOpen, setMemoOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -156,20 +162,50 @@ function SolveStudyLogBody() {
onToggle={() => setPassageCollapsed((prev) => !prev)}
/>
)}
<ZoomButton
type="button"
onClick={() => setLightboxOpen(true)}
aria-label={`${problem.problemSet.subjectName} ${problem.number}번 이미지 크게 보기`}
>
<DrawBar>
<DrawBtn type="button" $active={drawMode} onClick={() => setDrawMode((p) => !p)} title="필기">
<Icon name="pencil-simple" size={16} weight={drawMode ? 'fill' : 'regular'} />
</DrawBtn>
{drawMode && (
<>
{PEN_COLORS.map((c) => (
<PenDot key={c.value} type="button" $color={c.value} $active={penColor === c.value} onClick={() => setPenColor(c.value)} />
))}
<DrawBtn type="button" $active={false} onClick={() => setClearSignal((s) => s + 1)} title="지우기">
<Icon name="eraser" size={16} />
</DrawBtn>
</>
)}
<DrawBtn type="button" $active={false} onClick={() => setLightboxOpen(true)} title="확대">
<Icon name="export" size={16} />
</DrawBtn>
</DrawBar>
<ProblemImageWrap>
<ProblemStage
imageUrl={problemImageUrl}
alt={`${problem.problemSet.subjectName} ${problem.number}`}
/>
<ZoomHint>
<Icon name="export" size={14} />
</ZoomHint>
</ZoomButton>
<DrawingCanvas
storageKey={`reloop-solve-draw-${studyLogId}`}
visible={drawMode}
active={drawMode}
penColor={penColor}
clearSignal={clearSignal}
/>
</ProblemImageWrap>
<MemoBlock>
<MemoToggleBtn type="button" onClick={() => setMemoOpen((p) => !p)}>
<Icon name="pencil-simple" size={14} />
<Icon name="caret-down" size={12} weight="bold" />
</MemoToggleBtn>
{memoOpen && (
<MemoTextarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="풀이 전략이나 실수 포인트를 적어둬."
/>
)}
</MemoBlock>
</ProblemCard>
<AnswerCard>
@@ -466,6 +502,77 @@ const ResultBody = styled.p`
line-height: 1.6;
`;
const DrawBar = styled.div`
display: flex;
align-items: center;
gap: 6px;
`;
const DrawBtn = styled.button<{ $active: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid ${({ $active }) => ($active ? 'rgba(129,140,248,0.5)' : theme.color.borderSoftAlpha)};
background: ${({ $active }) => ($active ? 'rgba(79,70,229,0.15)' : 'rgba(255,255,255,0.03)')};
color: ${({ $active }) => ($active ? '#a5b4fc' : theme.color.textSub)};
cursor: pointer;
transition: all 0.15s;
&:hover { background: rgba(79,70,229,0.12); color: ${theme.color.textBright}; }
`;
const PenDot = styled.button<{ $color: string; $active: boolean }>`
width: 22px; height: 22px; border-radius: 50%;
border: 2px solid ${({ $active }) => ($active ? '#fff' : 'transparent')};
background: ${({ $color }) => $color}; cursor: pointer;
box-shadow: ${({ $active }) => ($active ? '0 0 0 2px rgba(255,255,255,0.3)' : 'none')};
`;
const ProblemImageWrap = styled.div`
position: relative;
border-radius: ${theme.radius.lg};
overflow: hidden;
border: 1px solid ${theme.color.borderSoftAlpha};
background: #fff;
`;
const MemoBlock = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const MemoToggleBtn = styled.button`
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: ${theme.color.textSub};
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 0;
&:hover { color: ${theme.color.textBright}; }
`;
const MemoTextarea = styled.textarea`
width: 100%;
min-height: 80px;
padding: 10px 12px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.03);
color: ${theme.color.textBright};
font-size: 13px;
resize: vertical;
outline: none;
&:focus { border-color: ${theme.color.brandIndigo}; }
&::placeholder { color: ${theme.color.textSub}; }
`;
const StateCard = styled(Card)`
display: inline-flex;
align-items: center;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import React, { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import AppShell from '@/components/layout/AppShell';
@@ -11,13 +11,11 @@ import {
Card,
ErrorText,
HelpText,
Label,
PageHeader,
SectionTitle,
Select,
Stack,
} from '@/components/ui/primitives';
import { api, resolveUploadUrl, type StudyResult, type Subject, type Tag } from '@/lib/api';
import { api, createProblemSetFromPdf, resolveUploadUrl } from '@/lib/api';
import { theme } from '@/styles/theme';
interface ParsedProblem {
@@ -34,12 +32,8 @@ interface ParseResult {
answersFound: number;
}
interface ProblemImportState {
interface ProblemState {
selected: boolean;
tagId: number | '';
result: StudyResult;
difficulty: number;
selfDifficulty: 'hard' | 'medium' | 'easy' | '';
correctAnswer: number | '';
}
@@ -57,35 +51,17 @@ function ImportPdfBody() {
const fileInputRef = useRef<HTMLInputElement>(null);
const answerFileInputRef = useRef<HTMLInputElement>(null);
const [subjects, setSubjects] = useState<Subject[] | null>(null);
const [subjectId, setSubjectId] = useState<number | ''>('');
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [answerPdfFile, setAnswerPdfFile] = useState<File | null>(null);
const [customTitle, setCustomTitle] = useState('');
const [parsing, setParsing] = useState(false);
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
const [problemStates, setProblemStates] = useState<ProblemImportState[]>([]);
const [problemStates, setProblemStates] = useState<ProblemState[]>([]);
const [importing, setImporting] = useState(false);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api
.get<Subject[]>('/subjects')
.then((r) => {
setSubjects(r.data);
if (r.data[0]) setSubjectId(r.data[0].id);
})
.catch(() => {
setSubjects([]);
showToast({ message: '과목 목록을 불러오지 못했습니다', variant: 'danger' });
});
}, [showToast]);
const currentSubject = subjects?.find((s) => s.id === subjectId) ?? null;
const tags: Tag[] = currentSubject?.tags ?? [];
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setPdfFile(file);
@@ -110,10 +86,6 @@ function ImportPdfBody() {
setErr('문제 PDF 파일을 선택해줘.');
return;
}
if (!subjectId) {
setErr('과목을 선택해줘.');
return;
}
setErr(null);
setParsing(true);
@@ -132,18 +104,14 @@ function ImportPdfBody() {
setParseResult(result);
setProblemStates(
result.problems.map((p) => ({
selected: false,
tagId: '',
result: 'incorrect' as StudyResult,
difficulty: 0.6,
selfDifficulty: '',
selected: true,
correctAnswer: p.correctAnswer ?? '',
})),
);
if (result.answersFound > 0) {
showToast({
message: `해설지에서 ${result.answersFound}개 정답을 자동으로 추출했어.`,
message: `해설지에서 ${result.answersFound}개 정답을 자동 추출했어.`,
variant: 'success',
});
}
@@ -154,10 +122,7 @@ function ImportPdfBody() {
}
};
const updateProblemState = (
index: number,
patch: Partial<ProblemImportState>,
) => {
const updateProblemState = (index: number, patch: Partial<ProblemState>) => {
setProblemStates((prev) =>
prev.map((state, i) => (i === index ? { ...state, ...patch } : state)),
);
@@ -169,60 +134,51 @@ function ImportPdfBody() {
const selectedCount = problemStates.filter((s) => s.selected).length;
const handleImport = async () => {
if (!parseResult || !subjectId) return;
const handleSave = async () => {
if (!parseResult) return;
const selectedProblems = parseResult.problems
.map((problem, i) => ({ problem, state: problemStates[i] }))
.filter(({ state }) => state.selected);
if (selectedProblems.length === 0) {
setErr('등록할 문제를 하나 이상 선택해줘.');
setErr('문제를 하나 이상 선택해줘.');
return;
}
setErr(null);
setImporting(true);
setSaving(true);
try {
await api.post('/study-logs/import-from-pdf', {
subjectId,
pdfTitle: parseResult.title,
const result = await createProblemSetFromPdf({
title: customTitle || parseResult.title,
subjectName: '수학',
problems: selectedProblems.map(({ problem, state }) => ({
number: problem.number,
title: `${problem.number}`,
difficulty: state.difficulty,
imageUrl: problem.imageUrl || undefined,
correctAnswer: state.correctAnswer !== '' ? state.correctAnswer : undefined,
tagId: state.tagId || undefined,
result: state.result,
selfDifficulty: state.selfDifficulty || undefined,
imageUrl: problem.imageUrl,
correctAnswer: state.correctAnswer !== '' ? Number(state.correctAnswer) : null,
})),
});
showToast({
message: `${selectedProblems.length}개 문제를 복습 큐에 등록했어.`,
message: `"${result.problemSet.title}" 문제집이 만들어졌어! (${selectedProblems.length}문제)`,
variant: 'success',
});
router.push('/dashboard');
router.push(`/exams/sample-ebook/${result.problemSet.id}`);
} catch {
setErr('등록에 실패했어. 다시 시도해줘.');
setErr('문제집 생성에 실패했어. 다시 시도해줘.');
} finally {
setImporting(false);
setSaving(false);
}
};
if (subjects === null) {
return <Loading> ...</Loading>;
}
return (
<Wrap>
<HeaderCard>
<PageHeader
eyebrow="Import"
title="PDF 가져오기"
subtitle="모의고사 PDF를 업로드하면 이미지 크롭으로 문제를 분리해서 복습 큐에 등록해줘."
title="PDF 문제집 만들기"
subtitle="모의고사 PDF를 업로드하면 문제를 이미지 크롭해서 나만의 문제집을 만들어줘."
right={
<HeaderBadge>
<Icon name="file-pdf" size={16} />
@@ -234,37 +190,17 @@ function ImportPdfBody() {
<StepCard>
<Stack $gap={theme.space.md}>
<SectionTitle>1단계: 파일 </SectionTitle>
<SectionTitle>1단계: PDF </SectionTitle>
<RowGrid>
<Field>
<Label></Label>
<FieldSelect
value={subjectId}
onChange={(e) => {
setSubjectId(Number(e.target.value));
setParseResult(null);
setProblemStates([]);
}}
>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</FieldSelect>
</Field>
<Field>
<Label> ()</Label>
<TitleInput
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="예) 2026 수능 수학"
/>
</Field>
</RowGrid>
<Field>
<Label> </Label>
<TitleInput
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="예) 2026 수능 수학"
/>
</Field>
<UploadGrid>
<UploadBlock>
@@ -290,7 +226,7 @@ function ImportPdfBody() {
<>
<Icon name="file-pdf" size={28} weight="regular" />
<UploadLabel> PDF </UploadLabel>
<HelpText> 50MB · PDF만 </HelpText>
<HelpText> 50MB</HelpText>
</>
)}
</UploadArea>
@@ -323,7 +259,7 @@ function ImportPdfBody() {
<>
<Icon name="file-pdf" size={28} weight="regular" />
<UploadLabel> PDF </UploadLabel>
<HelpText> </HelpText>
<HelpText> </HelpText>
</>
)}
</UploadArea>
@@ -356,7 +292,7 @@ function ImportPdfBody() {
<Stack $gap={theme.space.md}>
<SectionTitleRow>
<SectionTitle>
2단계: 문제 ({parseResult.problems.length} )
2단계: 문제 ({parseResult.problems.length} )
</SectionTitle>
<SelectAllRow>
<SmallButton type="button" onClick={() => toggleAll(true)}>
@@ -381,20 +317,46 @@ function ImportPdfBody() {
<span> . PDF인지 .</span>
</EmptyResult>
) : (
parseResult.problems.map((problem, index) => {
const state = problemStates[index];
if (!state) return null;
return (
<ProblemRow key={problem.number} $selected={state.selected}>
<ProblemCheckbox
type="checkbox"
checked={state.selected}
onChange={(e) =>
updateProblemState(index, { selected: e.target.checked })
}
/>
<ProblemContent>
<ProblemNumber>{problem.number}</ProblemNumber>
<ProblemGrid>
{parseResult.problems.map((problem, index) => {
const state = problemStates[index];
if (!state) return null;
return (
<ProblemCard key={problem.number} $selected={state.selected}>
<ProblemCardHeader>
<ProblemCheckbox
type="checkbox"
checked={state.selected}
onChange={(e) =>
updateProblemState(index, { selected: e.target.checked })
}
/>
<ProblemNumber>{problem.number}</ProblemNumber>
<AnswerControl>
<AnswerLabel>
{problem.correctAnswer !== null && (
<AutoBadge></AutoBadge>
)}
</AnswerLabel>
<MiniSelect
value={state.correctAnswer}
onChange={(e) =>
updateProblemState(index, {
correctAnswer:
e.target.value === '' ? '' : Number(e.target.value),
})
}
>
<option value="">-</option>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
<option value={5}>5</option>
</MiniSelect>
</AnswerControl>
</ProblemCardHeader>
<ProblemImageWrap>
<ProblemImage
src={resolveUploadUrl(problem.imageUrl) ?? problem.imageUrl}
@@ -402,92 +364,10 @@ function ImportPdfBody() {
loading="lazy"
/>
</ProblemImageWrap>
</ProblemContent>
<ProblemControls>
<MiniField>
<MiniLabel></MiniLabel>
<MiniSelect
value={state.tagId}
onChange={(e) =>
updateProblemState(index, {
tagId: e.target.value === '' ? '' : Number(e.target.value),
})
}
>
<option value=""></option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</MiniSelect>
</MiniField>
<MiniField>
<MiniLabel></MiniLabel>
<MiniSelect
value={state.result}
onChange={(e) =>
updateProblemState(index, {
result: e.target.value as StudyResult,
})
}
>
<option value="correct"></option>
<option value="incorrect"></option>
<option value="partial"></option>
</MiniSelect>
</MiniField>
<MiniField>
<MiniLabel> </MiniLabel>
<MiniSelect
value={state.selfDifficulty}
onChange={(e) =>
updateProblemState(index, {
selfDifficulty: e.target.value as
| 'hard'
| 'medium'
| 'easy'
| '',
})
}
>
<option value=""></option>
<option value="hard"></option>
<option value="medium"></option>
<option value="easy"></option>
</MiniSelect>
</MiniField>
<MiniField>
<MiniLabel>
{problem.correctAnswer !== null && (
<AutoBadge></AutoBadge>
)}
</MiniLabel>
<MiniSelect
value={state.correctAnswer}
onChange={(e) =>
updateProblemState(index, {
correctAnswer:
e.target.value === '' ? '' : Number(e.target.value),
})
}
>
<option value=""></option>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
<option value={5}>5</option>
</MiniSelect>
</MiniField>
</ProblemControls>
</ProblemRow>
);
})
</ProblemCard>
);
})}
</ProblemGrid>
)}
{err && (
@@ -501,7 +381,7 @@ function ImportPdfBody() {
<Button
type="button"
$variant="ghost"
disabled={importing}
disabled={saving}
onClick={() => {
setParseResult(null);
setProblemStates([]);
@@ -514,12 +394,12 @@ function ImportPdfBody() {
type="button"
$variant="primary"
$size="lg"
disabled={importing || selectedCount === 0}
onClick={handleImport}
disabled={saving || selectedCount === 0}
onClick={handleSave}
>
{importing
? '등록 중...'
: `${selectedCount}개 문제 복습 큐에 등록`}
{saving
? '생성 중...'
: `${selectedCount}개 문제로 문제집 만들기`}
</Button>
</ButtonRow>
</Stack>
@@ -562,13 +442,35 @@ const StepCard = styled(Card)`
border-color: ${theme.color.borderSoftAlpha};
`;
const RowGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: ${theme.space.md};
const Field = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
const Label = styled.label`
font-size: 14px;
font-weight: 600;
color: ${theme.color.textBright};
`;
const TitleInput = styled.input`
width: 100%;
height: 40px;
padding: 0 12px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
font-size: 14px;
outline: none;
&:focus {
border-color: ${theme.color.brandIndigo};
}
&::placeholder {
color: ${theme.color.textSub};
}
`;
@@ -607,38 +509,6 @@ const OptionalBadge = styled.span`
color: #10b981;
`;
const Field = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const FieldSelect = styled(Select)`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border-color: ${theme.color.borderSoftAlpha};
`;
const TitleInput = styled.input`
width: 100%;
height: 40px;
padding: 0 12px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textBright};
font-size: 14px;
outline: none;
&:focus {
border-color: ${theme.color.brandIndigo};
}
&::placeholder {
color: ${theme.color.textSub};
}
`;
const UploadArea = styled.div<{ $hasFile: boolean; $isOptional?: boolean }>`
display: flex;
flex-direction: column;
@@ -745,11 +615,17 @@ const SmallButton = styled.button`
}
`;
const ProblemRow = styled.div<{ $selected: boolean }>`
const ProblemGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: ${theme.space.md};
`;
const ProblemCard = styled.div<{ $selected: boolean }>`
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: ${theme.radius.lg};
border: 1px solid
${({ $selected }) =>
@@ -761,8 +637,13 @@ const ProblemRow = styled.div<{ $selected: boolean }>`
background 0.15s ease;
`;
const ProblemCardHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
`;
const ProblemCheckbox = styled.input`
margin-top: 2px;
width: 16px;
height: 16px;
flex-shrink: 0;
@@ -770,50 +651,20 @@ const ProblemCheckbox = styled.input`
cursor: pointer;
`;
const ProblemContent = styled.div`
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
`;
const ProblemNumber = styled.span`
font-weight: 700;
font-size: 14px;
color: ${theme.color.textBright};
flex: 1;
`;
const ProblemImageWrap = styled.div`
border-radius: ${theme.radius.md};
overflow: hidden;
border: 1px solid ${theme.color.borderSoftAlpha};
background: #fff;
max-width: 480px;
`;
const ProblemImage = styled.img`
display: block;
width: 100%;
height: auto;
object-fit: contain;
`;
const ProblemControls = styled.div`
const AnswerControl = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
min-width: 110px;
align-items: center;
gap: 6px;
`;
const MiniField = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const MiniLabel = styled.span`
const AnswerLabel = styled.span`
display: flex;
align-items: center;
gap: 4px;
@@ -832,8 +683,8 @@ const AutoBadge = styled.span`
`;
const MiniSelect = styled.select`
height: 32px;
padding: 0 8px;
height: 28px;
padding: 0 6px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
@@ -847,6 +698,20 @@ const MiniSelect = styled.select`
}
`;
const ProblemImageWrap = styled.div`
border-radius: ${theme.radius.md};
overflow: hidden;
border: 1px solid ${theme.color.borderSoftAlpha};
background: #fff;
`;
const ProblemImage = styled.img`
display: block;
width: 100%;
height: auto;
object-fit: contain;
`;
const EmptyResult = styled.div`
display: flex;
align-items: center;
@@ -855,11 +720,3 @@ const EmptyResult = styled.div`
color: ${theme.color.textSub};
font-size: 14px;
`;
const Loading = styled.div`
min-height: 40vh;
display: grid;
place-items: center;
color: ${theme.color.textSub};
font-family: ${theme.font.mono};
`;

View File

@@ -10,7 +10,6 @@ import {
Button,
Card,
ErrorText,
HelpText,
Input,
Label,
PageHeader,
@@ -38,7 +37,7 @@ function StudyBody() {
const [subjectId, setSubjectId] = useState<number | ''>('');
const [tagId, setTagId] = useState<number | ''>('');
const [title, setTitle] = useState('');
const [difficulty, setDifficulty] = useState(0.5);
const [selfDifficulty, setSelfDifficulty] = useState<'hard' | 'medium' | 'easy'>('medium');
const [baseCorrectRate, setBaseCorrectRate] = useState('');
const [result, setResult] = useState<StudyResult>('correct');
const [memo, setMemo] = useState('');
@@ -80,14 +79,16 @@ function StudyBody() {
setErr(null);
setSubmitting(true);
try {
const difficultyValue = selfDifficulty === 'hard' ? 0.9 : selfDifficulty === 'easy' ? 0.1 : 0.5;
await api.post('/study-logs', {
subjectId,
tagId: tagId || undefined,
title: title.trim(),
difficulty,
difficulty: difficultyValue,
baseCorrectRate:
baseCorrectRate === '' ? undefined : Number(baseCorrectRate) / 100,
result,
selfDifficulty,
memo: memo.trim() || undefined,
timeSpent: timeSpent === '' ? undefined : Number(timeSpent),
});
@@ -224,19 +225,39 @@ function StudyBody() {
</ResultGrid>
<div>
<SliderHeader>
<Label> </Label>
<SliderValue>{Math.round(difficulty * 100)}%</SliderValue>
</SliderHeader>
<RangeInput
type="range"
min={0}
max={1}
step={0.05}
value={difficulty}
onChange={(event) => setDifficulty(Number(event.target.value))}
/>
<HelpText>0% , 100% .</HelpText>
<Label> </Label>
<DifficultyGrid>
<DifficultyBtn
type="button"
$active={selfDifficulty === 'hard'}
$tone="danger"
onClick={() => setSelfDifficulty('hard')}
>
<span>🔴</span>
<DiffLabel></DiffLabel>
<DiffHint>1 </DiffHint>
</DifficultyBtn>
<DifficultyBtn
type="button"
$active={selfDifficulty === 'medium'}
$tone="warning"
onClick={() => setSelfDifficulty('medium')}
>
<span>🟡</span>
<DiffLabel></DiffLabel>
<DiffHint>3 </DiffHint>
</DifficultyBtn>
<DifficultyBtn
type="button"
$active={selfDifficulty === 'easy'}
$tone="success"
onClick={() => setSelfDifficulty('easy')}
>
<span>🟢</span>
<DiffLabel></DiffLabel>
<DiffHint>7 </DiffHint>
</DifficultyBtn>
</DifficultyGrid>
</div>
<Row>
@@ -422,23 +443,46 @@ const ResultLabel = styled.span`
font-weight: 700;
`;
const SliderHeader = styled.div`
const DifficultyGrid = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 8px;
`;
const DifficultyBtn = styled.button<{ $active: boolean; $tone: 'danger' | 'warning' | 'success' }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 6px;
padding: 14px 10px;
border-radius: ${theme.radius.lg};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
transition: all 0.15s;
${({ $active, $tone }) =>
$active &&
css`
border-color: ${theme.color[$tone]};
background: ${$tone === 'success'
? 'rgba(34, 197, 94, 0.12)'
: $tone === 'warning'
? 'rgba(245, 158, 11, 0.12)'
: 'rgba(239, 68, 68, 0.12)'};
`}
`;
const SliderValue = styled.span`
const DiffLabel = styled.span`
font-size: 14px;
font-weight: 700;
color: ${theme.color.textBright};
font-family: ${theme.font.mono};
font-size: 13px;
`;
const RangeInput = styled.input`
width: 100%;
margin: 6px 0 10px;
accent-color: ${theme.color.brandIndigo};
const DiffHint = styled.span`
font-size: 11px;
color: ${theme.color.textSub};
`;
const ErrorBox = styled.div`

View File

@@ -1,94 +0,0 @@
'use client';
import React from 'react';
import {
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { theme } from '@/styles/theme';
import type { ForgetCurveResponse } from '@/lib/api';
interface Props {
data: ForgetCurveResponse;
threshold?: number;
}
export default function ForgetCurveChart({ data, threshold = 0.5 }: Props) {
const points = data.points.map((p) => ({
day: Number(p.t.toFixed(2)),
memory: Number((p.s * 100).toFixed(1)),
p: Number((p.p * 100).toFixed(1)),
}));
return (
<div style={{ width: '100%', height: 320 }}>
<ResponsiveContainer>
<LineChart data={points} margin={{ top: 12, right: 16, left: 0, bottom: 8 }}>
<CartesianGrid stroke={theme.color.border} strokeDasharray="3 3" />
<XAxis
dataKey="day"
stroke={theme.color.textMute}
tick={{ fontSize: 11 }}
label={{
value: '일수',
position: 'insideBottomRight',
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}일 후`}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine
y={threshold * 100}
stroke={theme.color.warning}
strokeDasharray="4 4"
label={{
value: `임계값 ${Math.round(threshold * 100)}%`,
fill: theme.color.warning,
fontSize: 10,
position: 'insideTopRight',
}}
/>
<Line
type="monotone"
dataKey="memory"
name="기억 강도 S(t)"
stroke={theme.color.accent}
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="p"
name="예상 정답률"
stroke={theme.color.accent2}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -1,98 +0,0 @@
'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}%`, '예상 정답률'];
if (name === 's0') return [`${value}%`, '기억 강도 S₀'];
if (name === 'days') return [`${value}일 뒤`, '예상'];
return [value, name];
}}
/>
<ReferenceLine
y={Math.round(data.effectiveTarget * 100)}
stroke={theme.color.success}
strokeDasharray="5 5"
label={{
value: `목표 ${Math.round(data.effectiveTarget * 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

@@ -14,7 +14,6 @@ const BASE_TABS = [
{ href: '/review', label: '복습', icon: 'arrows-clockwise' as const },
{ href: '/exams', label: '문제집', icon: 'books' as const },
{ href: '/subjects', label: '과목', icon: 'folders' as const },
{ href: '/stats', label: '통계', icon: 'trend-up' as const },
{ href: '/profile', label: '프로필', icon: 'user' as const },
];

View File

@@ -37,7 +37,7 @@ const BASE_NAV_ITEMS: Array<{
},
{
href: '/study/import-pdf',
label: 'PDF 문제 분리',
label: 'PDF 문제',
icon: 'file-pdf',
match: (pathname) => pathname.startsWith('/study/import-pdf'),
},
@@ -48,12 +48,6 @@ const BASE_NAV_ITEMS: Array<{
match: (pathname) => pathname.startsWith('/review'),
showBadge: true,
},
{
href: '/stats',
label: '통계',
icon: 'trend-up',
match: (pathname) => pathname.startsWith('/stats'),
},
{
href: '/subjects',
label: '과목 및 태그',

View File

@@ -0,0 +1,205 @@
'use client';
import React, { useCallback, useEffect, useRef } from 'react';
import styled from 'styled-components';
export const PEN_COLORS = [
{ label: '빨강', value: '#ef4444' },
{ label: '파랑', value: '#3b82f6' },
{ label: '검정', value: '#1e1e1e' },
] as const;
export type PenColor = (typeof PEN_COLORS)[number]['value'];
interface DrawingCanvasProps {
/** localStorage 저장 키 */
storageKey: string;
visible: boolean;
active: boolean;
penColor: PenColor;
/** 이 값이 바뀌면 (0 제외) 캔버스 초기화 */
clearSignal: number;
}
export default function DrawingCanvas({
storageKey,
visible,
active,
penColor,
clearSignal,
}: DrawingCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawing = useRef(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncSize = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
const { width, height } = parent.getBoundingClientRect();
if (width === 0 || height === 0) return;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) tempCtx.drawImage(canvas, 0, 0);
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const saved = localStorage.getItem(storageKey);
if (saved) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = saved;
} else if (tempCanvas.width > 0 && tempCanvas.height > 0) {
ctx.drawImage(tempCanvas, 0, 0);
}
}, [storageKey]);
useEffect(() => {
syncSize();
}, [syncSize]);
useEffect(() => {
const handler = () => syncSize();
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [syncSize]);
useEffect(() => {
if (clearSignal === 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
localStorage.removeItem(storageKey);
}, [clearSignal, storageKey]);
const scheduleSave = useCallback(() => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const canvas = canvasRef.current;
if (!canvas) return;
try {
localStorage.setItem(storageKey, canvas.toDataURL('image/png'));
} catch {
/* localStorage full */
}
}, 500);
}, [storageKey]);
const getCtx = () => {
const canvas = canvasRef.current;
if (!canvas) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.strokeStyle = penColor;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
return ctx;
};
const getPos = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top };
};
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!active) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = getCtx();
if (!ctx) return;
isDrawing.current = true;
const { x, y } = getPos(canvas, e.clientX, e.clientY);
ctx.beginPath();
ctx.moveTo(x, y);
};
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing.current || !active) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = getCtx();
if (!ctx) return;
const { x, y } = getPos(canvas, e.clientX, e.clientY);
ctx.lineTo(x, y);
ctx.stroke();
};
const onMouseUp = () => {
if (!isDrawing.current) return;
isDrawing.current = false;
scheduleSave();
};
const onTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
if (!active) return;
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = getCtx();
if (!ctx) return;
isDrawing.current = true;
const touch = e.touches[0];
const { x, y } = getPos(canvas, touch.clientX, touch.clientY);
ctx.beginPath();
ctx.moveTo(x, y);
};
const onTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
if (!isDrawing.current || !active) return;
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = getCtx();
if (!ctx) return;
const touch = e.touches[0];
const { x, y } = getPos(canvas, touch.clientX, touch.clientY);
ctx.lineTo(x, y);
ctx.stroke();
};
const onTouchEnd = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
if (!isDrawing.current) return;
isDrawing.current = false;
scheduleSave();
};
return (
<CanvasEl
ref={canvasRef}
$visible={visible}
$active={active}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
);
}
const CanvasEl = styled.canvas<{ $visible: boolean; $active: boolean }>`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: ${({ $visible }) => ($visible ? 5 : -1)};
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
pointer-events: ${({ $active }) => ($active ? 'auto' : 'none')};
cursor: ${({ $active }) => ($active ? 'crosshair' : 'default')};
touch-action: ${({ $active }) => ($active ? 'none' : 'auto')};
`;

View File

@@ -504,6 +504,33 @@ export async function getProblemSets(params?: {
return response.data;
}
export async function getMyUploadedProblemSets() {
const response = await api.get<ProblemSetSummary[]>('/problem-sets/my-uploads');
return response.data;
}
export async function createProblemSetFromPdf(payload: {
title: string;
subjectName?: string;
problems: Array<{
number: number;
imageUrl: string;
correctAnswer?: number | null;
difficulty?: number;
}>;
}) {
const response = await api.post<{
problemSet: ProblemSetSummary;
problems: Problem[];
}>('/problem-sets/create-from-pdf', payload);
return response.data;
}
export async function deleteProblemSet(id: number) {
const response = await api.delete(`/problem-sets/${id}`);
return response.data;
}
export async function getProblemSet(id: number) {
const response = await api.get<ProblemSetDetail>(`/problem-sets/${id}`);
return response.data;

File diff suppressed because one or more lines are too long