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:
3
.claude/logs/subagent-history.jsonl
Normal file
3
.claude/logs/subagent-history.jsonl
Normal 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":""}
|
||||
@@ -1 +1 @@
|
||||
1775919509
|
||||
1776387808
|
||||
|
||||
30
.claude/state/breezing-timeline.jsonl
Normal file
30
.claude/state/breezing-timeline.jsonl
Normal 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"}
|
||||
2
.claude/state/compaction-events.jsonl
Normal file
2
.claude/state/compaction-events.jsonl
Normal 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"}
|
||||
28
.claude/state/contracts/7A.1.sprint-contract.json
Normal file
28
.claude/state/contracts/7A.1.sprint-contract.json
Normal 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/"
|
||||
}
|
||||
27
.claude/state/contracts/7A.2.sprint-contract.json
Normal file
27
.claude/state/contracts/7A.2.sprint-contract.json
Normal 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 추가"
|
||||
}
|
||||
27
.claude/state/contracts/7A.3.sprint-contract.json
Normal file
27
.claude/state/contracts/7A.3.sprint-contract.json
Normal 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 의존 추가"
|
||||
}
|
||||
29
.claude/state/contracts/7A.4.sprint-contract.json
Normal file
29
.claude/state/contracts/7A.4.sprint-contract.json
Normal 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"
|
||||
}
|
||||
26
.claude/state/contracts/7A.5.sprint-contract.json
Normal file
26
.claude/state/contracts/7A.5.sprint-contract.json
Normal 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 추출 기능 확장"
|
||||
}
|
||||
27
.claude/state/contracts/7A.6.sprint-contract.json
Normal file
27
.claude/state/contracts/7A.6.sprint-contract.json
Normal 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 함수"
|
||||
}
|
||||
28
.claude/state/contracts/7A.7.sprint-contract.json
Normal file
28
.claude/state/contracts/7A.7.sprint-contract.json
Normal 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 쿼리 통합"
|
||||
}
|
||||
24
.claude/state/contracts/7A.8.sprint-contract.json
Normal file
24
.claude/state/contracts/7A.8.sprint-contract.json
Normal 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"
|
||||
}
|
||||
64
.claude/state/contracts/7D.1.sprint-contract.json
Normal file
64
.claude/state/contracts/7D.1.sprint-contract.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
464
.claude/state/handoff-artifact.json
Normal file
464
.claude/state/handoff-artifact.json
Normal 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
|
||||
}
|
||||
1
.claude/state/phase7d-base-ref
Normal file
1
.claude/state/phase7d-base-ref
Normal file
@@ -0,0 +1 @@
|
||||
BASE_REF=9c3efeb4641856a668bf836f0c50575422d6eb84
|
||||
@@ -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
|
||||
|
||||
464
.claude/state/precompact-snapshot.json
Normal file
464
.claude/state/precompact-snapshot.json
Normal 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
|
||||
}
|
||||
152
.claude/state/task-quality-gate.json
Normal file
152
.claude/state/task-quality-gate.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
1 1775919057
|
||||
1 1776387989
|
||||
|
||||
1
.claude/state/worktree-info.json
Normal file
1
.claude/state/worktree-info.json
Normal file
@@ -0,0 +1 @@
|
||||
{"worker_id": "d97749a0-5ebc-42a9-b387-b9abff64fd38", "created_at": "2026-04-11T16:46:46Z", "cwd": "/home/erang/reloop-v2"}
|
||||
1
backend/.claude/logs/subagent-history.jsonl
Normal file
1
backend/.claude/logs/subagent-history.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"event":"subagent_stop","timestamp":"2026-04-11T18:44:34Z","agent_id":"unknown","transcript_path":"","transcript_preview":""}
|
||||
1
backend/.claude/sessions/.last_inbox_check
Normal file
1
backend/.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
||||
1776388140
|
||||
9
backend/.claude/state/breezing-timeline.jsonl
Normal file
9
backend/.claude/state/breezing-timeline.jsonl
Normal 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"}
|
||||
1
backend/.claude/state/compaction-events.jsonl
Normal file
1
backend/.claude/state/compaction-events.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"event": "post_compact", "has_wip": "false", "has_snapshot": "false", "has_handoff": "false", "timestamp": "2026-04-11T18:44:35Z"}
|
||||
0
backend/.claude/state/session-events.lock
Normal file
0
backend/.claude/state/session-events.lock
Normal file
0
backend/.claude/state/session.events.jsonl
Normal file
0
backend/.claude/state/session.events.jsonl
Normal file
0
backend/.claude/state/session.json
Normal file
0
backend/.claude/state/session.json
Normal file
47
backend/.claude/state/task-quality-gate.json
Normal file
47
backend/.claude/state/task-quality-gate.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
backend/.claude/state/test-recommendation.json
Normal file
7
backend/.claude/state/test-recommendation.json
Normal 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": "テストの実行を推奨します"
|
||||
}
|
||||
1
backend/.claude/state/tool-failure-counter.txt
Normal file
1
backend/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
||||
0 0
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
1
backend/tsconfig.build.tsbuildinfo
Normal file
1
backend/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
backend/tsconfig.tsbuildinfo
Normal file
1
backend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
frontend/.claude/sessions/.last_inbox_check
Normal file
1
frontend/.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
||||
1776388416
|
||||
1
frontend/.claude/state/breezing-timeline.jsonl
Normal file
1
frontend/.claude/state/breezing-timeline.jsonl
Normal 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"}
|
||||
7
frontend/.claude/state/task-quality-gate.json
Normal file
7
frontend/.claude/state/task-quality-gate.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"14": {
|
||||
"failure_count": 0,
|
||||
"last_action": "reset",
|
||||
"updated_at": "2026-04-17T01:13:06Z"
|
||||
}
|
||||
}
|
||||
1
frontend/.claude/state/tool-failure-counter.txt
Normal file
1
frontend/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
||||
1 1775933439
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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};
|
||||
`;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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: '과목 및 태그',
|
||||
|
||||
205
frontend/src/components/ui/DrawingCanvas.tsx
Normal file
205
frontend/src/components/ui/DrawingCanvas.tsx
Normal 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')};
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user