From 06fca8d92951351bd2a3d0652de5682c5d983342 Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:07:17 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20=EB=B3=B5=EC=8A=B5=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-31-review-screen.md | 933 ++++++++++++++++++ 1 file changed, 933 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-review-screen.md diff --git a/docs/superpowers/plans/2026-07-31-review-screen.md b/docs/superpowers/plans/2026-07-31-review-screen.md new file mode 100644 index 0000000..fb3d2e6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-review-screen.md @@ -0,0 +1,933 @@ +# 복습 화면 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 오답노트·즐겨찾기·최근 푼 문제 3개 탭으로 구성된 복습 화면을 끝까지 동작하게 만든다 — 목록 조회 → 개별 제거 → 전체 다시 풀기(학습모드 재사용) → 결과 요약까지. + +**Architecture:** 학습모드 문제풀이 계획(`docs/superpowers/plans/2026-07-30-practice-mvp-foundation.md`)이 만든 `PracticeSession`/`QuestionCard`/`ProgressRepository`/`QuestionRepository`를 그대로 재사용한다. 새 화면은 그 위에 조회·삭제 기능만 얹는다. + +**Tech Stack:** 기존과 동일 — Next.js 16 / React 19 / TypeScript / Tailwind CSS 4 / Vitest / `idb` + +## Scope 결정 (사전 조사 결과) + +설계문서 2절: 복습 메뉴 Phase 1 범위는 "오답노트, 즐겨찾기, 틀린 문제 다시 풀기, 최근 푼 문제 다시 풀기" — SRS·Priority·자동 복습 큐·망각곡선은 Phase 2. 이 계획은 그 4개 항목만 만든다. + +**사전 조사에서 찾은 진짜 문제 (계획 단계 필수 반영)**: +1. **`ProgressRepository`에 조회 메서드가 없다.** 학습모드 계획이 고정한 인터페이스는 `addWrongNote`/`addFavorite`(쓰기)만 있고 `getWrongNotes`/`getFavorites`(읽기), 제거 메서드가 아예 없다 — 복습 화면은 원래 인터페이스만으로 만들 수 없다. Task 1에서 인터페이스를 확장한다(기존 메서드 시그니처는 안 건드림, 추가만). +2. **즐겨찾기를 추가할 UI 자체가 없다.** `addFavorite`를 부르는 곳이 코드베이스 전체에 0곳(`grep` 확인) — 지금 상태로 복습 화면을 만들어도 즐겨찾기 탭은 영원히 빈 채로 남는다. Task 4에서 학습모드 화면(`QuestionCard`/`PracticeSession`)에 즐겨찾기 버튼을 추가한다. +3. **`theory_map.json` 직접 fetch 문제(이전 최종 리뷰에서 이월 보류됐던 것)를 이번에 해소한다.** `app/practice/page.tsx`가 Repository를 안 거치고 `fetch("/data/theory_map.json")`을 직접 호출하던 걸 이번 계획의 새 화면(`app/review/page.tsx`)도 그대로 필요로 하게 되는데, 두 번째 소비자가 생기는 시점이 바로 지금이다. `QuestionRepository.getTheoryMap()`으로 승격해 두 화면이 공유·캐싱한다(Task 1). + +**의도적으로 이번에 안 하는 것**: +- 오답 재도전 성공 시 오답노트 자동 제거("졸업" 판정) — SRS/Priority 영역이라 Phase 2. 이번엔 사용자가 "제거" 버튼을 직접 눌러야 목록에서 빠진다. +- 개별 문항 단위 "다시 풀기"(리스트 아이템 하나만 재도전) — 설계문서 문구가 "다시 풀기"를 세트 단위로만 표현하므로 목록 전체 재도전(`전체 다시 풀기`)만 만든다. +- 학습모드에서 이미 즐겨찾기한 문항의 해제(unfavorite) — 추가는 학습모드에서, 제거는 복습 화면에서만 가능(왕복 토글은 다음 계획). +- 최근 푼 문제 탭의 "제거" — 이 탭은 `Attempt` 로그에서 파생된 뷰라 저장소 자체가 없다. 지울 대상이 없으므로 제거 버튼도 없다. + +## Global Constraints + +- 설계문서 4절 원칙 8번: 모든 비즈니스 로직은 순수 함수로, UI·Repository에 직접 의존하지 않는다. +- 설계문서 8절: `/lib`의 순수 함수만 Vitest로 테스트한다. UI 컴포넌트 자동 테스트는 Phase 1 범위 밖 — 수동 QA로 대체. +- `ProgressRepository`/`QuestionRepository`의 **기존** 메서드 시그니처는 절대 변경하지 않는다(학습모드 화면이 이미 소비 중) — 이번 계획은 확장만 한다. +- `Question.answer: number | number[]`(복수정답) 처리는 `PracticeSession`/`QuestionCard`가 이미 담당 — 이 계획에서 재구현하지 않는다, 그대로 재사용. +- `QuestionCard`는 "표시만 담당, 내부 상태 없음" 원칙을 유지한다(학습모드 최종 리뷰에서 확인된 강점) — 즐겨찾기 클릭 후 상태(이미 눌렀는지)는 부모(`PracticeSession`)가 들고 `isFavorited` prop으로 내려준다. + +--- + +## 파일 구조 + +```text +src/types/progress.ts WrongNote, Favorite 타입 추가 (기존 Attempt/QuestionStats/DashboardSummary 유지) +src/lib/recentlySolved.ts getRecentlySolvedQuestionIds (신규, 순수 함수) + +src/repositories/QuestionRepository.ts getTheoryMap 추가 +src/repositories/ProgressRepository.ts getWrongNotes/getFavorites/removeWrongNote/removeFavorite 추가 + +src/features/practice/QuestionCard.tsx 즐겨찾기 버튼 추가 +src/features/practice/PracticeSession.tsx 즐겨찾기 상태·핸들러 추가 + +src/features/review/ReviewList.tsx 신규 — 목록 표시 + 제거 + 전체 다시 풀기 +src/app/review/page.tsx 신규 — 탭 3개 + 목록/재도전/완료 phase +src/app/page.tsx "복습" 진입 링크 추가 (수정) +``` + +--- + +### Task 1: `getTheoryMap` (QuestionRepository) + `WrongNote`/`Favorite` 조회·제거 (ProgressRepository) + +**Files:** +- Modify: `src/repositories/QuestionRepository.ts` +- Modify: `src/repositories/QuestionRepository.test.ts` +- Modify: `src/app/practice/page.tsx` +- Modify: `src/types/progress.ts` +- Modify: `src/repositories/ProgressRepository.ts` +- Modify: `src/repositories/ProgressRepository.test.ts` + +**Interfaces:** +- Produces: + - `QuestionRepository.getTheoryMap(): Promise` + - `interface WrongNote { questionId: string; addedAt: number }` + - `interface Favorite { questionId: string; addedAt: number }` + - `ProgressRepository.getWrongNotes(): Promise` + - `ProgressRepository.getFavorites(): Promise` + - `ProgressRepository.removeWrongNote(questionId: string): Promise` + - `ProgressRepository.removeFavorite(questionId: string): Promise` + +- [ ] **Step 1: `src/types/progress.ts`에 타입 추가** + +```ts +export type Mode = "exam" | "study"; + +export interface Attempt { + id?: number; + questionId: string; + solvedAt: number; + mode: Mode; + selectedAnswer: number; + isCorrect: boolean; + solveTimeMs: number; +} + +export interface QuestionStats { + questionId: string; + correctCount: number; + wrongCount: number; + lastSolvedAt: number; +} + +export interface DashboardSummary { + todayCount: number; + todayAccuracy: number; + totalCount: number; + totalAccuracy: number; +} + +export interface WrongNote { + questionId: string; + addedAt: number; +} + +export interface Favorite { + questionId: string; + addedAt: number; +} +``` + +(기존 4개는 그대로 두고 `WrongNote`/`Favorite` 두 개만 새로 추가한다.) + +- [ ] **Step 2: 실패하는 테스트 작성 — `QuestionRepository.test.ts`에 `getTheoryMap` 케이스 추가** + +파일 맨 위 fixture 영역에 `theoryMapFixture`를 추가하고, `mockFetchJson`이 `theory_map` URL도 분기하도록 고친 뒤, 테스트 3개를 `describe("JsonQuestionRepository")` 블록 끝에 추가한다. + +```ts +// mockFetchJson 함수 교체 +const theoryMapFixture = { + "075": { tag: null, name: "스택(Stack)", page: 25, subject: "2과목 소프트웨어 개발" }, +}; + +function mockFetchJson(url: string) { + const body = url.includes("exams_index") + ? examsIndexFixture + : url.includes("theory_map") + ? theoryMapFixture + : url.includes("exam_2023-1") + ? exam2023_1 + : exam2023_2; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(body), + } as Response); +} +``` + +`describe` 블록 끝(마지막 `it` 다음)에 추가: + +```ts + it("getTheoryMap은 /data/theory_map.json을 fetch해서 반환한다", async () => { + const repo = new JsonQuestionRepository(); + const map = await repo.getTheoryMap(); + expect(map["075"].name).toBe("스택(Stack)"); + expect(fetch).toHaveBeenCalledWith("/data/theory_map.json"); + }); + + it("getTheoryMap을 두 번 불러도 fetch는 한 번만 일어난다 (캐시)", async () => { + const repo = new JsonQuestionRepository(); + await repo.getTheoryMap(); + await repo.getTheoryMap(); + const calls = (fetch as ReturnType).mock.calls.filter(([url]) => + String(url).includes("theory_map") + ); + expect(calls).toHaveLength(1); + }); + + it("getTheoryMap의 fetch가 실패해도 캐시에 남지 않아 재시도 시 다시 fetch한다", async () => { + const repo = new JsonQuestionRepository(); + const mockFetch = vi.fn(mockFetchJson); + mockFetch.mockRejectedValueOnce(new Error("network error")); + vi.stubGlobal("fetch", mockFetch); + + await expect(repo.getTheoryMap()).rejects.toThrow("network error"); + + const map = await repo.getTheoryMap(); + expect(map["075"].name).toBe("스택(Stack)"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +``` + +- [ ] **Step 3: 테스트 실패 확인** + +```bash +npx vitest run src/repositories/QuestionRepository.test.ts +``` + +Expected: FAIL — `repo.getTheoryMap is not a function` + +- [ ] **Step 4: 구현 — `QuestionRepository.ts`에 `getTheoryMap` 추가** + +인터페이스와 클래스 양쪽에 추가한다. `loadIndex`와 완전히 같은 캐싱 패턴(거부 시 캐시 비우고 rethrow)을 따른다. + +```ts +import { makeQuestionId, parseQuestionId } from "@/lib/questionId"; +import type { ExamSummary, Question } from "@/types/question"; +import type { TheoryMap } from "@/types/theory"; + +export interface QuestionRepository { + getQuestion(questionId: string): Promise; + getQuestions(filter: { examId?: string; subject?: number }): Promise; + getTheoryMap(): Promise; +} +``` + +`JsonQuestionRepository` 클래스에 필드와 메서드 추가(기존 `examCache`/`indexCache` 옆에): + +```ts + private theoryMapCache: Promise | null = null; + + async getTheoryMap(): Promise { + if (!this.theoryMapCache) { + this.theoryMapCache = fetch("/data/theory_map.json") + .then((res) => res.json() as Promise) + .catch((err) => { + this.theoryMapCache = null; + throw err; + }); + } + return this.theoryMapCache; + } +``` + +- [ ] **Step 5: 테스트 통과 확인** + +```bash +npx vitest run src/repositories/QuestionRepository.test.ts +``` + +Expected: PASS (8 tests — 기존 5개 + 신규 3개) + +- [ ] **Step 6: `app/practice/page.tsx`가 직접 fetch 대신 `getTheoryMap()`을 쓰도록 수정** + +```ts +const [pool, theoryMap] = await Promise.all([ + questionRepository.getQuestions( + value.subject === "all" ? {} : { subject: value.subject } + ), + questionRepository.getTheoryMap(), +]); +``` + +(기존 `fetch("/data/theory_map.json").then(...)` 줄을 지우고 위로 교체. `TheoryMap` import는 더 이상 `page.tsx`에서 직접 안 써도 되면 지운다 — `Phase` 타입 선언에서 여전히 쓰므로 import는 유지.) + +- [ ] **Step 7: 실패하는 테스트 작성 — `ProgressRepository.test.ts`에 조회/제거 케이스 추가** + +`describe` 블록의 "addWrongNote / addFavorite는 questionId를 저장한다" 테스트 바로 뒤에 추가: + +```ts + it("getWrongNotes / getFavorites는 추가한 항목을 전부 반환한다", async () => { + const repo = new IndexedDbProgressRepository(); + await repo.addWrongNote("Q1"); + await repo.addWrongNote("Q2"); + await repo.addFavorite("Q3"); + + const wrongNotes = await repo.getWrongNotes(); + const favorites = await repo.getFavorites(); + + expect(wrongNotes.map((n) => n.questionId).sort()).toEqual(["Q1", "Q2"]); + expect(favorites.map((n) => n.questionId)).toEqual(["Q3"]); + }); + + it("아무것도 추가 안 했으면 getWrongNotes / getFavorites는 빈 배열", async () => { + const repo = new IndexedDbProgressRepository(); + expect(await repo.getWrongNotes()).toEqual([]); + expect(await repo.getFavorites()).toEqual([]); + }); + + it("removeWrongNote / removeFavorite는 해당 항목만 지운다", async () => { + const repo = new IndexedDbProgressRepository(); + await repo.addWrongNote("Q1"); + await repo.addWrongNote("Q2"); + await repo.addFavorite("Q3"); + + await repo.removeWrongNote("Q1"); + await repo.removeFavorite("Q3"); + + expect((await repo.getWrongNotes()).map((n) => n.questionId)).toEqual(["Q2"]); + expect(await repo.getFavorites()).toEqual([]); + }); + + it("같은 questionId를 두 번 addWrongNote해도 중복 저장되지 않는다 (upsert)", async () => { + const repo = new IndexedDbProgressRepository(); + await repo.addWrongNote("Q1"); + await repo.addWrongNote("Q1"); + expect(await repo.getWrongNotes()).toHaveLength(1); + }); +``` + +- [ ] **Step 8: 테스트 실패 확인** + +```bash +npx vitest run src/repositories/ProgressRepository.test.ts +``` + +Expected: FAIL — `repo.getWrongNotes is not a function` + +- [ ] **Step 9: 구현 — `ProgressRepository.ts`에 조회·제거 메서드 추가** + +인터페이스에 추가: + +```ts +import { getDb } from "./db"; +import { isSameLocalDay } from "@/lib/timer"; +import type { Attempt, DashboardSummary, Favorite, QuestionStats, WrongNote } from "@/types/progress"; + +export interface ProgressRepository { + recordAttempt(attempt: Omit): Promise; + getAttempts(questionId?: string): Promise; + getQuestionStats(questionId: string): Promise; + getDashboardSummary(): Promise; + addWrongNote(questionId: string): Promise; + addFavorite(questionId: string): Promise; + getWrongNotes(): Promise; + getFavorites(): Promise; + removeWrongNote(questionId: string): Promise; + removeFavorite(questionId: string): Promise; +} +``` + +클래스에 메서드 추가(기존 `addWrongNote`/`addFavorite` 아래): + +```ts + async getWrongNotes(): Promise { + const db = await getDb(); + return db.getAll("wrongNotes"); + } + + async getFavorites(): Promise { + const db = await getDb(); + return db.getAll("favorites"); + } + + async removeWrongNote(questionId: string): Promise { + const db = await getDb(); + await db.delete("wrongNotes", questionId); + } + + async removeFavorite(questionId: string): Promise { + const db = await getDb(); + await db.delete("favorites", questionId); + } +``` + +`db.ts`는 수정하지 않는다 — `wrongNotes`/`favorites` 스토어와 `keyPath: "questionId"`는 이미 있고, `getAll`/`delete`는 idb가 스키마만 있으면 바로 쓸 수 있다. + +- [ ] **Step 10: 테스트 통과 확인** + +```bash +npx vitest run src/repositories/ProgressRepository.test.ts +``` + +Expected: PASS (9 tests — 기존 5개 + 신규 4개) + +- [ ] **Step 11: 전체 확인** + +```bash +npx vitest run +npx tsc --noEmit +npm run build +``` + +Expected: 전체 통과, `/practice`·`/` 라우트 정상. + +- [ ] **Step 12: Commit** + +```bash +git add src/types/progress.ts src/repositories/QuestionRepository.ts src/repositories/QuestionRepository.test.ts src/repositories/ProgressRepository.ts src/repositories/ProgressRepository.test.ts src/app/practice/page.tsx +git commit -m "feat: theory_map 캐싱 Repository로 승격 + 오답노트/즐겨찾기 조회·제거 API" +``` + +--- + +### Task 2: 최근 푼 문제 추출 (순수 함수) + +**Files:** +- Create: `src/lib/recentlySolved.ts` +- Create: `src/lib/recentlySolved.test.ts` + +**Interfaces:** +- Consumes: `Attempt` (`src/types/progress.ts`, Task 1) +- Produces: `getRecentlySolvedQuestionIds(attempts: Attempt[], limit: number): string[]` + +- [ ] **Step 1: 실패하는 테스트 작성** + +```ts +import { describe, expect, it } from "vitest"; +import { getRecentlySolvedQuestionIds } from "./recentlySolved"; +import type { Attempt } from "@/types/progress"; + +function makeAttempt(overrides: Partial): Attempt { + return { + questionId: "Q1", + solvedAt: 1000, + mode: "study", + selectedAnswer: 1, + isCorrect: true, + solveTimeMs: 1000, + ...overrides, + }; +} + +describe("getRecentlySolvedQuestionIds", () => { + it("빈 배열이면 빈 배열을 반환한다", () => { + expect(getRecentlySolvedQuestionIds([], 10)).toEqual([]); + }); + + it("최근 푼 순서(내림차순)로 questionId를 반환한다", () => { + const attempts = [ + makeAttempt({ questionId: "Q1", solvedAt: 1000 }), + makeAttempt({ questionId: "Q2", solvedAt: 3000 }), + makeAttempt({ questionId: "Q3", solvedAt: 2000 }), + ]; + expect(getRecentlySolvedQuestionIds(attempts, 10)).toEqual(["Q2", "Q3", "Q1"]); + }); + + it("같은 문제를 여러 번 풀었으면 가장 최근 시각 기준으로 한 번만 나온다", () => { + const attempts = [ + makeAttempt({ questionId: "Q1", solvedAt: 1000 }), + makeAttempt({ questionId: "Q2", solvedAt: 2000 }), + makeAttempt({ questionId: "Q1", solvedAt: 5000 }), + ]; + expect(getRecentlySolvedQuestionIds(attempts, 10)).toEqual(["Q1", "Q2"]); + }); + + it("limit만큼만 반환한다", () => { + const attempts = [ + makeAttempt({ questionId: "Q1", solvedAt: 1000 }), + makeAttempt({ questionId: "Q2", solvedAt: 2000 }), + makeAttempt({ questionId: "Q3", solvedAt: 3000 }), + ]; + expect(getRecentlySolvedQuestionIds(attempts, 2)).toEqual(["Q3", "Q2"]); + }); +}); +``` + +- [ ] **Step 2: 테스트 실패 확인** + +```bash +npx vitest run src/lib/recentlySolved.test.ts +``` + +Expected: FAIL — `Cannot find module './recentlySolved'` + +- [ ] **Step 3: 구현 — `src/lib/recentlySolved.ts`** + +```ts +import type { Attempt } from "@/types/progress"; + +export function getRecentlySolvedQuestionIds(attempts: Attempt[], limit: number): string[] { + const latestByQuestion = new Map(); + for (const attempt of attempts) { + const prev = latestByQuestion.get(attempt.questionId); + if (prev === undefined || attempt.solvedAt > prev) { + latestByQuestion.set(attempt.questionId, attempt.solvedAt); + } + } + + return [...latestByQuestion.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([questionId]) => questionId); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run src/lib/recentlySolved.test.ts +``` + +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/recentlySolved.ts src/lib/recentlySolved.test.ts +git commit -m "feat: 최근 푼 문제 추출 순수 함수" +``` + +--- + +### Task 3: 학습모드 화면에 즐겨찾기 추가 + +**Files:** +- Modify: `src/features/practice/QuestionCard.tsx` +- Modify: `src/features/practice/PracticeSession.tsx` + +**Interfaces:** +- Consumes: `ProgressRepository.addFavorite` (기존, 이미 구현됨) +- Produces: `QuestionCardProps`에 `isFavorited: boolean`, `onFavorite: () => void` 추가 + +- [ ] **Step 1: `QuestionCard.tsx`에 즐겨찾기 버튼 추가** + +`index / total` 표시 줄을 아래로 교체(같은 줄에 버튼 추가): + +```tsx +interface QuestionCardProps { + question: Question; + index: number; + total: number; + selectedAnswer: number | null; + theoryLink: TheoryLink | null; + isFavorited: boolean; + onSelect: (answer: number) => void; + onFavorite: () => void; +} + +export function QuestionCard({ + question, + index, + total, + selectedAnswer, + theoryLink, + isFavorited, + onSelect, + onFavorite, +}: QuestionCardProps) { + const isGraded = selectedAnswer !== null; + const isCorrect = isGraded && isCorrectOption(question, selectedAnswer); + + return ( +
+
+ + {index + 1} / {total} + + +
+``` + +(이 아래 `

{question.stem}

` 부터는 기존 그대로 — `div` 하나 안에 있던 `index/total` 줄만 위 블록으로 교체하고 나머지 JSX는 손대지 않는다.) + +- [ ] **Step 2: `PracticeSession.tsx`에 즐겨찾기 상태·핸들러 추가** + +`answers` state 옆에 `favorited` state를 추가하고, `select` 함수 아래에 `toggleFavorite` 함수를 추가한다. + +```ts + const [current, setCurrent] = useState(0); + const [answers, setAnswers] = useState>({}); + const [favorited, setFavorited] = useState>({}); + const [questionStartedAt, setQuestionStartedAt] = useState(() => Date.now()); +``` + +`select` 함수 바로 아래에 추가: + +```ts + function toggleFavorite() { + if (favorited[current]) return; + setFavorited((prev) => ({ ...prev, [current]: true })); + progressRepository + .addFavorite(question.questionId) + .catch((err) => console.error("addFavorite failed:", err)); + } +``` + +`` 호출부에 prop 2개 추가: + +```tsx + +``` + +- [ ] **Step 3: 빌드·전체 테스트 확인** + +```bash +npx vitest run +npx tsc --noEmit +npm run build +``` + +Expected: 전체 통과(이 태스크는 UI만 건드려 새 자동 테스트 없음 — 설계문서 8절 방침). + +- [ ] **Step 4: 수동 QA** + +```bash +npm run data +npm run dev +``` + +`/practice`에서 문제풀이 시작 → 문항 상단 "☆ 즐겨찾기" 클릭 → "★ 즐겨찾기 완료"로 바뀌고 재클릭 안 먹는지 확인 → 개발자도구 IndexedDB → `passflow` → `favorites`에 항목이 생겼는지 확인. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/practice/QuestionCard.tsx src/features/practice/PracticeSession.tsx +git commit -m "feat: 학습모드 화면에 즐겨찾기 버튼 추가" +``` + +--- + +### Task 4: 복습 목록 표시 컴포넌트 + +**Files:** +- Create: `src/features/review/ReviewList.tsx` + +**Interfaces:** +- Consumes: `Question` (`src/types/question.ts`) +- Produces: `ReviewListProps`, `ReviewList` 컴포넌트 + +이 태스크는 UI 컴포넌트라 설계문서 8절 방침대로 자동 테스트를 붙이지 않는다 — Task 6에서 실제 페이지에 연결한 뒤 한 번에 수동 QA한다. + +- [ ] **Step 1: `src/features/review/ReviewList.tsx` 작성** + +```tsx +"use client"; + +import type { Question } from "@/types/question"; + +interface ReviewListProps { + questions: Question[]; + emptyMessage: string; + onRemove?: (questionId: string) => void; + onRetryAll: () => void; +} + +export function ReviewList({ questions, emptyMessage, onRemove, onRetryAll }: ReviewListProps) { + if (questions.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ +
    + {questions.map((question) => ( +
  • + {question.stem} + {onRemove && ( + + )} +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: 타입체크 확인** + +```bash +npx tsc --noEmit +``` + +Expected: 에러 없음(아직 아무도 이 컴포넌트를 쓰지 않으니 unused-export 경고만 없으면 됨). + +- [ ] **Step 3: Commit** + +```bash +git add src/features/review/ReviewList.tsx +git commit -m "feat: 복습 목록 표시 컴포넌트" +``` + +--- + +### Task 5: 복습 페이지 (탭 3개 + 재도전 흐름) + +**Files:** +- Create: `src/app/review/page.tsx` +- Modify: `src/app/page.tsx` + +**Interfaces:** +- Consumes: `ReviewList`(Task 4), `getRecentlySolvedQuestionIds`(Task 2), `getTheoryMap`/`getWrongNotes`/`getFavorites`/`removeWrongNote`/`removeFavorite`(Task 1), `PracticeSession`/`SessionSummary`(기존 학습모드 계획 산출물) + +- [ ] **Step 1: `src/app/review/page.tsx` 작성** + +```tsx +"use client"; + +import { useEffect, useState } from "react"; +import { ReviewList } from "@/features/review/ReviewList"; +import { PracticeSession } from "@/features/practice/PracticeSession"; +import { getRecentlySolvedQuestionIds } from "@/lib/recentlySolved"; +import type { SessionSummary } from "@/lib/summary"; +import { JsonQuestionRepository } from "@/repositories/QuestionRepository"; +import { IndexedDbProgressRepository } from "@/repositories/ProgressRepository"; +import type { Question } from "@/types/question"; +import type { TheoryMap } from "@/types/theory"; + +const questionRepository = new JsonQuestionRepository(); +const progressRepository = new IndexedDbProgressRepository(); + +type Tab = "wrong" | "favorite" | "recent"; + +type Phase = + | { kind: "list" } + | { kind: "active"; questions: Question[]; theoryMap: TheoryMap } + | { kind: "done"; summary: SessionSummary } + | { kind: "error"; message: string }; + +const TAB_LABEL: Record = { + wrong: "오답노트", + favorite: "즐겨찾기", + recent: "최근 푼 문제", +}; + +const EMPTY_MESSAGE: Record = { + wrong: "오답노트가 비어있다.", + favorite: "즐겨찾기한 문제가 없다.", + recent: "최근 푼 문제가 없다.", +}; + +async function hydrate(questionIds: string[]): Promise { + const results = await Promise.allSettled( + questionIds.map((id) => questionRepository.getQuestion(id)) + ); + return results + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value); +} + +export default function ReviewPage() { + const [tab, setTab] = useState("wrong"); + const [phase, setPhase] = useState({ kind: "list" }); + const [questions, setQuestions] = useState([]); + const [loading, setLoading] = useState(true); + + async function loadTab(nextTab: Tab) { + setLoading(true); + try { + let questionIds: string[]; + if (nextTab === "wrong") { + questionIds = (await progressRepository.getWrongNotes()).map((n) => n.questionId); + } else if (nextTab === "favorite") { + questionIds = (await progressRepository.getFavorites()).map((n) => n.questionId); + } else { + const attempts = await progressRepository.getAttempts(); + questionIds = getRecentlySolvedQuestionIds(attempts, 20); + } + setQuestions(await hydrate(questionIds)); + } catch { + setQuestions([]); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadTab(tab); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + + async function handleRemove(questionId: string) { + if (tab === "wrong") { + await progressRepository.removeWrongNote(questionId).catch((err) => console.error(err)); + } else if (tab === "favorite") { + await progressRepository.removeFavorite(questionId).catch((err) => console.error(err)); + } + setQuestions((prev) => prev.filter((q) => q.questionId !== questionId)); + } + + async function handleRetryAll() { + try { + const theoryMap = await questionRepository.getTheoryMap(); + setPhase({ kind: "active", questions, theoryMap }); + } catch { + setPhase({ kind: "error", message: "관련 이론 데이터를 불러오지 못했다. 다시 시도해달라." }); + } + } + + if (phase.kind === "error") { + return ( +
+

{phase.message}

+ +
+ ); + } + + if (phase.kind === "done") { + const { total, solved, correct, wrong } = phase.summary; + return ( +
+

복습 완료.

+

+ {total}문제 중 {solved}문제 풀이 — 정답 {correct} · 오답 {wrong} +

+ +
+ ); + } + + if (phase.kind === "active") { + return ( + setPhase({ kind: "done", summary })} + /> + ); + } + + return ( +
+
+ {(["wrong", "favorite", "recent"] as const).map((t) => ( + + ))} +
+ {loading ? ( +

불러오는 중...

+ ) : ( + + )} +
+ ); +} +``` + +- [ ] **Step 2: 홈에 복습 진입 링크 추가** + +`src/app/page.tsx`: + +```tsx +import Link from "next/link"; + +export default function Home() { + return ( +
+

PassFlow

+
+ + 문제풀이 시작 (학습모드) + + + 복습 + +
+
+ ); +} +``` + +- [ ] **Step 3: 빌드 확인** + +```bash +npm run build +``` + +Expected: 에러 없이 통과, `/review` 라우트가 목록에 나타남. + +- [ ] **Step 4: 수동 QA (설계문서 8절 — UI는 자동 테스트 대상 아님)** + +```bash +npm run data +npm run dev +``` + +1. `/practice`에서 문제 하나를 **오답**으로 풀고, 하나는 **즐겨찾기** 클릭 후 종료 +2. `/` → "복습" 클릭 → 오답노트 탭에 방금 틀린 문제가 보이는지 확인 +3. 즐겨찾기 탭으로 전환 → 방금 즐겨찾기한 문제가 보이는지 확인 +4. 최근 푼 문제 탭 → 방금 푼 문제들이 최신순으로 보이는지, 제거 버튼이 **없는지** 확인 +5. 오답노트 탭에서 항목 하나 "제거" → 목록에서 사라지는지, 개발자도구 IndexedDB `wrongNotes`에서도 지워졌는지 확인 +6. 오답노트 탭에서 "전체 다시 풀기" → 학습모드 화면 그대로 뜨는지(키보드 1~4/Space/←→ 다 되는지) → 다 풀면 "복습 완료 — N문제 중 M문제 풀이 정답X 오답Y" 뜨는지 확인 +7. "복습 목록으로" 클릭 → 방금 푼 내역이 최근 푼 문제 탭에 반영되는지 확인 +8. 오답노트·즐겨찾기 둘 다 비어있는 상태에서 각 탭에 안내 문구("오답노트가 비어있다." 등)가 뜨는지 확인 + +전부 통과하면 다음 단계로. 하나라도 깨지면 해당 스텝으로 돌아가 원인을 고친다. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/review/page.tsx src/app/page.tsx +git commit -m "feat: 복습 화면 (오답노트/즐겨찾기/최근 푼 문제 + 전체 다시 풀기)" +``` + +--- + +## Self-Review 결과 + +**Spec coverage**: 설계문서 2절 복습 4개 항목(오답노트→Task 5, 즐겨찾기→Task 3+5, 틀린 문제 다시 풀기→Task 5 `handleRetryAll`, 최근 푼 문제 다시 풀기→Task 2+5) 전부 대응. Scope 결정에서 명시적으로 뺀 것(자동 졸업, 개별 재도전, 언페이버릿, 최근탭 제거)은 설계문서에 없는 항목이라 제외가 맞음. + +**Placeholder scan**: 전체 재확인 — "TODO"/"나중에" 패턴 없음. 모든 코드 블록이 실제 최종 코드. + +**Type consistency**: `WrongNote`/`Favorite`(Task 1) → Task 5에서 `.questionId` 필드로 동일하게 소비. `getRecentlySolvedQuestionIds`(Task 2) 시그니처 `(attempts, limit) => string[]` → Task 5에서 `getAttempts()`·`20` 그대로 호출. `ReviewListProps`(Task 4) → Task 5에서 동일 필드(`questions`/`emptyMessage`/`onRemove`/`onRetryAll`)로 소비. `QuestionCard`의 `isFavorited`/`onFavorite`(Task 3) 신규 prop이 기존 `PracticeSession` 호출부와 시그니처 일치. From ee66fa88e47eb45f6ff7b854a78bbe092b9d57cd Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:13:49 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20theory=5Fmap=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=20Repository=EB=A1=9C=20=EC=8A=B9=EA=B2=A9=20+=20=EC=98=A4?= =?UTF-8?q?=EB=8B=B5=EB=85=B8=ED=8A=B8/=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=A0=9C=EA=B1=B0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/app/practice/page.tsx | 2 +- src/repositories/ProgressRepository.test.ts | 39 +++++++++++++++++++ src/repositories/ProgressRepository.ts | 26 ++++++++++++- src/repositories/QuestionRepository.test.ts | 42 +++++++++++++++++++-- src/repositories/QuestionRepository.ts | 15 ++++++++ src/types/progress.ts | 10 +++++ 6 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/app/practice/page.tsx b/src/app/practice/page.tsx index 5ec221e..c0effe9 100644 --- a/src/app/practice/page.tsx +++ b/src/app/practice/page.tsx @@ -29,7 +29,7 @@ export default function PracticePage() { questionRepository.getQuestions( value.subject === "all" ? {} : { subject: value.subject } ), - fetch("/data/theory_map.json").then((res) => res.json() as Promise), + questionRepository.getTheoryMap(), ]); const questions = pickRandomQuestions(pool, value.count); diff --git a/src/repositories/ProgressRepository.test.ts b/src/repositories/ProgressRepository.test.ts index 4f7a3d0..5ef04c6 100644 --- a/src/repositories/ProgressRepository.test.ts +++ b/src/repositories/ProgressRepository.test.ts @@ -78,6 +78,45 @@ describe("IndexedDbProgressRepository", () => { expect(await db.get("favorites", "2023-1-Q1")).toBeDefined(); }); + it("getWrongNotes / getFavorites는 추가한 항목을 전부 반환한다", async () => { + const repo = new IndexedDbProgressRepository(); + await repo.addWrongNote("Q1"); + await repo.addWrongNote("Q2"); + await repo.addFavorite("Q3"); + + const wrongNotes = await repo.getWrongNotes(); + const favorites = await repo.getFavorites(); + + expect(wrongNotes.map((n) => n.questionId).sort()).toEqual(["Q1", "Q2"]); + expect(favorites.map((n) => n.questionId)).toEqual(["Q3"]); + }); + + it("아무것도 추가 안 했으면 getWrongNotes / getFavorites는 빈 배열", async () => { + const repo = new IndexedDbProgressRepository(); + expect(await repo.getWrongNotes()).toEqual([]); + expect(await repo.getFavorites()).toEqual([]); + }); + + it("removeWrongNote / removeFavorite는 해당 항목만 지운다", async () => { + const repo = new IndexedDbProgressRepository(); + await repo.addWrongNote("Q1"); + await repo.addWrongNote("Q2"); + await repo.addFavorite("Q3"); + + await repo.removeWrongNote("Q1"); + await repo.removeFavorite("Q3"); + + expect((await repo.getWrongNotes()).map((n) => n.questionId)).toEqual(["Q2"]); + expect(await repo.getFavorites()).toEqual([]); + }); + + it("같은 questionId를 두 번 addWrongNote해도 중복 저장되지 않는다 (upsert)", async () => { + const repo = new IndexedDbProgressRepository(); + await repo.addWrongNote("Q1"); + await repo.addWrongNote("Q1"); + expect(await repo.getWrongNotes()).toHaveLength(1); + }); + it("getDashboardSummary는 오늘/전체 정답률을 계산한다", async () => { const repo = new IndexedDbProgressRepository(); const oneDayMs = 24 * 60 * 60 * 1000; diff --git a/src/repositories/ProgressRepository.ts b/src/repositories/ProgressRepository.ts index 73ade53..04fecc4 100644 --- a/src/repositories/ProgressRepository.ts +++ b/src/repositories/ProgressRepository.ts @@ -1,6 +1,6 @@ import { getDb } from "./db"; import { isSameLocalDay } from "@/lib/timer"; -import type { Attempt, DashboardSummary, QuestionStats } from "@/types/progress"; +import type { Attempt, DashboardSummary, Favorite, QuestionStats, WrongNote } from "@/types/progress"; export interface ProgressRepository { recordAttempt(attempt: Omit): Promise; @@ -9,6 +9,10 @@ export interface ProgressRepository { getDashboardSummary(): Promise; addWrongNote(questionId: string): Promise; addFavorite(questionId: string): Promise; + getWrongNotes(): Promise; + getFavorites(): Promise; + removeWrongNote(questionId: string): Promise; + removeFavorite(questionId: string): Promise; } export class IndexedDbProgressRepository implements ProgressRepository { @@ -83,4 +87,24 @@ export class IndexedDbProgressRepository implements ProgressRepository { const db = await getDb(); await db.put("favorites", { questionId, addedAt: Date.now() }); } + + async getWrongNotes(): Promise { + const db = await getDb(); + return db.getAll("wrongNotes"); + } + + async getFavorites(): Promise { + const db = await getDb(); + return db.getAll("favorites"); + } + + async removeWrongNote(questionId: string): Promise { + const db = await getDb(); + await db.delete("wrongNotes", questionId); + } + + async removeFavorite(questionId: string): Promise { + const db = await getDb(); + await db.delete("favorites", questionId); + } } diff --git a/src/repositories/QuestionRepository.test.ts b/src/repositories/QuestionRepository.test.ts index 99f48c8..080ceff 100644 --- a/src/repositories/QuestionRepository.test.ts +++ b/src/repositories/QuestionRepository.test.ts @@ -51,12 +51,18 @@ const exam2023_2 = { ], }; +const theoryMapFixture = { + "075": { tag: null, name: "스택(Stack)", page: 25, subject: "2과목 소프트웨어 개발" }, +}; + function mockFetchJson(url: string) { const body = url.includes("exams_index") ? examsIndexFixture - : url.includes("exam_2023-1") - ? exam2023_1 - : exam2023_2; + : url.includes("theory_map") + ? theoryMapFixture + : url.includes("exam_2023-1") + ? exam2023_1 + : exam2023_2; return Promise.resolve({ ok: true, json: () => Promise.resolve(body), @@ -134,4 +140,34 @@ describe("JsonQuestionRepository", () => { expect(qs.map((q) => q.questionId).sort()).toEqual(["2023-1-Q1", "2023-2-Q1"]); expect(mockFetch).toHaveBeenCalledTimes(4); }); + + it("getTheoryMap은 /data/theory_map.json을 fetch해서 반환한다", async () => { + const repo = new JsonQuestionRepository(); + const map = await repo.getTheoryMap(); + expect(map["075"].name).toBe("스택(Stack)"); + expect(fetch).toHaveBeenCalledWith("/data/theory_map.json"); + }); + + it("getTheoryMap을 두 번 불러도 fetch는 한 번만 일어난다 (캐시)", async () => { + const repo = new JsonQuestionRepository(); + await repo.getTheoryMap(); + await repo.getTheoryMap(); + const calls = (fetch as ReturnType).mock.calls.filter(([url]) => + String(url).includes("theory_map") + ); + expect(calls).toHaveLength(1); + }); + + it("getTheoryMap의 fetch가 실패해도 캐시에 남지 않아 재시도 시 다시 fetch한다", async () => { + const repo = new JsonQuestionRepository(); + const mockFetch = vi.fn(mockFetchJson); + mockFetch.mockRejectedValueOnce(new Error("network error")); + vi.stubGlobal("fetch", mockFetch); + + await expect(repo.getTheoryMap()).rejects.toThrow("network error"); + + const map = await repo.getTheoryMap(); + expect(map["075"].name).toBe("스택(Stack)"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/repositories/QuestionRepository.ts b/src/repositories/QuestionRepository.ts index 9629b2b..ae0fc74 100644 --- a/src/repositories/QuestionRepository.ts +++ b/src/repositories/QuestionRepository.ts @@ -1,9 +1,11 @@ import { makeQuestionId, parseQuestionId } from "@/lib/questionId"; import type { ExamSummary, Question } from "@/types/question"; +import type { TheoryMap } from "@/types/theory"; export interface QuestionRepository { getQuestion(questionId: string): Promise; getQuestions(filter: { examId?: string; subject?: number }): Promise; + getTheoryMap(): Promise; } interface RawQuestion { @@ -27,6 +29,7 @@ interface RawExam { export class JsonQuestionRepository implements QuestionRepository { private examCache = new Map>(); private indexCache: Promise | null = null; + private theoryMapCache: Promise | null = null; private loadExam(examId: string): Promise { let cached = this.examCache.get(examId); @@ -70,6 +73,18 @@ export class JsonQuestionRepository implements QuestionRepository { return this.indexCache; } + async getTheoryMap(): Promise { + if (!this.theoryMapCache) { + this.theoryMapCache = fetch("/data/theory_map.json") + .then((res) => res.json() as Promise) + .catch((err) => { + this.theoryMapCache = null; + throw err; + }); + } + return this.theoryMapCache; + } + async getQuestion(questionId: string): Promise { const { examId, qnum } = parseQuestionId(questionId); const questions = await this.loadExam(examId); diff --git a/src/types/progress.ts b/src/types/progress.ts index a886516..f84c71a 100644 --- a/src/types/progress.ts +++ b/src/types/progress.ts @@ -23,3 +23,13 @@ export interface DashboardSummary { totalCount: number; totalAccuracy: number; } + +export interface WrongNote { + questionId: string; + addedAt: number; +} + +export interface Favorite { + questionId: string; + addedAt: number; +} From eb9068c6c232230e684a38c4d5fd1e60206c43be Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:18:14 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=ED=91=BC=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=B6=94=EC=B6=9C=20=EC=88=9C=EC=88=98=20?= =?UTF-8?q?=ED=95=A8=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/recentlySolved.test.ts | 48 ++++++++++++++++++++++++++++++++++ src/lib/recentlySolved.ts | 16 ++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/lib/recentlySolved.test.ts create mode 100644 src/lib/recentlySolved.ts diff --git a/src/lib/recentlySolved.test.ts b/src/lib/recentlySolved.test.ts new file mode 100644 index 0000000..9fad600 --- /dev/null +++ b/src/lib/recentlySolved.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { getRecentlySolvedQuestionIds } from "./recentlySolved"; +import type { Attempt } from "@/types/progress"; + +function makeAttempt(overrides: Partial): Attempt { + return { + questionId: "Q1", + solvedAt: 1000, + mode: "study", + selectedAnswer: 1, + isCorrect: true, + solveTimeMs: 1000, + ...overrides, + }; +} + +describe("getRecentlySolvedQuestionIds", () => { + it("빈 배열이면 빈 배열을 반환한다", () => { + expect(getRecentlySolvedQuestionIds([], 10)).toEqual([]); + }); + + it("최근 푼 순서(내림차순)로 questionId를 반환한다", () => { + const attempts = [ + makeAttempt({ questionId: "Q1", solvedAt: 1000 }), + makeAttempt({ questionId: "Q2", solvedAt: 3000 }), + makeAttempt({ questionId: "Q3", solvedAt: 2000 }), + ]; + expect(getRecentlySolvedQuestionIds(attempts, 10)).toEqual(["Q2", "Q3", "Q1"]); + }); + + it("같은 문제를 여러 번 풀었으면 가장 최근 시각 기준으로 한 번만 나온다", () => { + const attempts = [ + makeAttempt({ questionId: "Q1", solvedAt: 1000 }), + makeAttempt({ questionId: "Q2", solvedAt: 2000 }), + makeAttempt({ questionId: "Q1", solvedAt: 5000 }), + ]; + expect(getRecentlySolvedQuestionIds(attempts, 10)).toEqual(["Q1", "Q2"]); + }); + + it("limit만큼만 반환한다", () => { + const attempts = [ + makeAttempt({ questionId: "Q1", solvedAt: 1000 }), + makeAttempt({ questionId: "Q2", solvedAt: 2000 }), + makeAttempt({ questionId: "Q3", solvedAt: 3000 }), + ]; + expect(getRecentlySolvedQuestionIds(attempts, 2)).toEqual(["Q3", "Q2"]); + }); +}); diff --git a/src/lib/recentlySolved.ts b/src/lib/recentlySolved.ts new file mode 100644 index 0000000..1f485d4 --- /dev/null +++ b/src/lib/recentlySolved.ts @@ -0,0 +1,16 @@ +import type { Attempt } from "@/types/progress"; + +export function getRecentlySolvedQuestionIds(attempts: Attempt[], limit: number): string[] { + const latestByQuestion = new Map(); + for (const attempt of attempts) { + const prev = latestByQuestion.get(attempt.questionId); + if (prev === undefined || attempt.solvedAt > prev) { + latestByQuestion.set(attempt.questionId, attempt.solvedAt); + } + } + + return [...latestByQuestion.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([questionId]) => questionId); +} From f973d8fe80784bd08ba0b546bc382bd3f19b0afe Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:21:56 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=ED=95=99=EC=8A=B5=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=A6=90=EA=B2=A8?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/practice/PracticeSession.tsx | 11 +++++++++++ src/features/practice/QuestionCard.tsx | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/features/practice/PracticeSession.tsx b/src/features/practice/PracticeSession.tsx index deee6f9..fd93c73 100644 --- a/src/features/practice/PracticeSession.tsx +++ b/src/features/practice/PracticeSession.tsx @@ -20,6 +20,7 @@ const progressRepository = new IndexedDbProgressRepository(); export function PracticeSession({ questions, theoryMap, onFinish }: PracticeSessionProps) { const [current, setCurrent] = useState(0); const [answers, setAnswers] = useState>({}); + const [favorited, setFavorited] = useState>({}); const [questionStartedAt, setQuestionStartedAt] = useState(() => Date.now()); const question = questions[current]; @@ -58,6 +59,14 @@ export function PracticeSession({ questions, theoryMap, onFinish }: PracticeSess } } + function toggleFavorite() { + if (favorited[current]) return; + setFavorited((prev) => ({ ...prev, [current]: true })); + progressRepository + .addFavorite(question.questionId) + .catch((err) => console.error("addFavorite failed:", err)); + } + useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (e.ctrlKey || e.metaKey || e.altKey) return; @@ -97,7 +106,9 @@ export function PracticeSession({ questions, theoryMap, onFinish }: PracticeSess total={questions.length} selectedAnswer={selectedAnswer} theoryLink={selectedAnswer !== null ? theoryLink : null} + isFavorited={favorited[current] ?? false} onSelect={select} + onFavorite={toggleFavorite} />

{question.stem}

From 3ecfe2d1860cc9c3f02da4eea86cfe4012776e37 Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:25:19 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EB=B3=B5=EC=8A=B5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=91=9C=EC=8B=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/review/ReviewList.tsx | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/features/review/ReviewList.tsx diff --git a/src/features/review/ReviewList.tsx b/src/features/review/ReviewList.tsx new file mode 100644 index 0000000..9da3651 --- /dev/null +++ b/src/features/review/ReviewList.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { Question } from "@/types/question"; + +interface ReviewListProps { + questions: Question[]; + emptyMessage: string; + onRemove?: (questionId: string) => void; + onRetryAll: () => void; +} + +export function ReviewList({ questions, emptyMessage, onRemove, onRetryAll }: ReviewListProps) { + if (questions.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ +
    + {questions.map((question) => ( +
  • + {question.stem} + {onRemove && ( + + )} +
  • + ))} +
+
+ ); +} From 26900732fcf5ad83cb2a10180da9f6352475119c Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:30:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EB=B3=B5=EC=8A=B5=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20(=EC=98=A4=EB=8B=B5=EB=85=B8=ED=8A=B8/=EC=A6=90?= =?UTF-8?q?=EA=B2=A8=EC=B0=BE=EA=B8=B0/=EC=B5=9C=EA=B7=BC=20=ED=91=BC=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20+=20=EC=A0=84=EC=B2=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=ED=92=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 20 +++-- src/app/review/page.tsx | 167 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 src/app/review/page.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 152c909..b49105c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,12 +4,20 @@ export default function Home() { return (

PassFlow

- - 문제풀이 시작 (학습모드) - +
+ + 문제풀이 시작 (학습모드) + + + 복습 + +
); } diff --git a/src/app/review/page.tsx b/src/app/review/page.tsx new file mode 100644 index 0000000..aadd11a --- /dev/null +++ b/src/app/review/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ReviewList } from "@/features/review/ReviewList"; +import { PracticeSession } from "@/features/practice/PracticeSession"; +import { getRecentlySolvedQuestionIds } from "@/lib/recentlySolved"; +import type { SessionSummary } from "@/lib/summary"; +import { JsonQuestionRepository } from "@/repositories/QuestionRepository"; +import { IndexedDbProgressRepository } from "@/repositories/ProgressRepository"; +import type { Question } from "@/types/question"; +import type { TheoryMap } from "@/types/theory"; + +const questionRepository = new JsonQuestionRepository(); +const progressRepository = new IndexedDbProgressRepository(); + +type Tab = "wrong" | "favorite" | "recent"; + +type Phase = + | { kind: "list" } + | { kind: "active"; questions: Question[]; theoryMap: TheoryMap } + | { kind: "done"; summary: SessionSummary } + | { kind: "error"; message: string }; + +const TAB_LABEL: Record = { + wrong: "오답노트", + favorite: "즐겨찾기", + recent: "최근 푼 문제", +}; + +const EMPTY_MESSAGE: Record = { + wrong: "오답노트가 비어있다.", + favorite: "즐겨찾기한 문제가 없다.", + recent: "최근 푼 문제가 없다.", +}; + +async function hydrate(questionIds: string[]): Promise { + const results = await Promise.allSettled( + questionIds.map((id) => questionRepository.getQuestion(id)) + ); + return results + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value); +} + +export default function ReviewPage() { + const [tab, setTab] = useState("wrong"); + const [phase, setPhase] = useState({ kind: "list" }); + const [questions, setQuestions] = useState([]); + const [loading, setLoading] = useState(true); + + async function loadTab(nextTab: Tab) { + setLoading(true); + try { + let questionIds: string[]; + if (nextTab === "wrong") { + questionIds = (await progressRepository.getWrongNotes()).map((n) => n.questionId); + } else if (nextTab === "favorite") { + questionIds = (await progressRepository.getFavorites()).map((n) => n.questionId); + } else { + const attempts = await progressRepository.getAttempts(); + questionIds = getRecentlySolvedQuestionIds(attempts, 20); + } + setQuestions(await hydrate(questionIds)); + } catch { + setQuestions([]); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadTab(tab); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + + async function handleRemove(questionId: string) { + if (tab === "wrong") { + await progressRepository.removeWrongNote(questionId).catch((err) => console.error(err)); + } else if (tab === "favorite") { + await progressRepository.removeFavorite(questionId).catch((err) => console.error(err)); + } + setQuestions((prev) => prev.filter((q) => q.questionId !== questionId)); + } + + async function handleRetryAll() { + try { + const theoryMap = await questionRepository.getTheoryMap(); + setPhase({ kind: "active", questions, theoryMap }); + } catch { + setPhase({ kind: "error", message: "관련 이론 데이터를 불러오지 못했다. 다시 시도해달라." }); + } + } + + if (phase.kind === "error") { + return ( +
+

{phase.message}

+ +
+ ); + } + + if (phase.kind === "done") { + const { total, solved, correct, wrong } = phase.summary; + return ( +
+

복습 완료.

+

+ {total}문제 중 {solved}문제 풀이 — 정답 {correct} · 오답 {wrong} +

+ +
+ ); + } + + if (phase.kind === "active") { + return ( + setPhase({ kind: "done", summary })} + /> + ); + } + + return ( +
+
+ {(["wrong", "favorite", "recent"] as const).map((t) => ( + + ))} +
+ {loading ? ( +

불러오는 중...

+ ) : ( + + )} +
+ ); +} From 1dccf2e935a5ec319b3bc4fbda3bdac5825ad3b7 Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:35:10 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=EB=B3=B5=EC=8A=B5=20=ED=83=AD=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=8B=9C=20=EB=8A=A6=EA=B2=8C=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=ED=95=9C=20=EC=9D=91=EB=8B=B5=EC=9D=B4=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=ED=83=AD=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EB=8A=94=20=EB=A0=88=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=BB=A8=EB=94=94=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/review/page.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/review/page.tsx b/src/app/review/page.tsx index aadd11a..7d08cea 100644 --- a/src/app/review/page.tsx +++ b/src/app/review/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ReviewList } from "@/features/review/ReviewList"; import { PracticeSession } from "@/features/practice/PracticeSession"; import { getRecentlySolvedQuestionIds } from "@/lib/recentlySolved"; @@ -47,8 +47,10 @@ export default function ReviewPage() { const [phase, setPhase] = useState({ kind: "list" }); const [questions, setQuestions] = useState([]); const [loading, setLoading] = useState(true); + const latestRequestId = useRef(0); async function loadTab(nextTab: Tab) { + const requestId = ++latestRequestId.current; setLoading(true); try { let questionIds: string[]; @@ -60,11 +62,14 @@ export default function ReviewPage() { const attempts = await progressRepository.getAttempts(); questionIds = getRecentlySolvedQuestionIds(attempts, 20); } - setQuestions(await hydrate(questionIds)); + const hydrated = await hydrate(questionIds); + if (requestId !== latestRequestId.current) return; + setQuestions(hydrated); } catch { + if (requestId !== latestRequestId.current) return; setQuestions([]); } finally { - setLoading(false); + if (requestId === latestRequestId.current) setLoading(false); } } From 6bb3d063a63175018dac79c70ee1772f22ff0bbf Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 00:55:49 +0900 Subject: [PATCH 08/10] fix(review): resolve set-state-in-effect lint error and log tab load failures The prescribed fix (moving setLoading(true) into the effect/call sites) still tripped react-hooks/set-state-in-effect, since the rule flags any function invoked from useEffect that transitively calls a state setter anywhere in its body, regardless of await position. Restructured to derive `loading` from a loadedTab comparison instead of an effect-driven boolean, and moved the fetch's state application into an inline .then() callback (the pattern React's own docs use), which the rule doesn't trace. Also logs the actual error via console.error on tab-load failure instead of silently showing an empty list. --- src/app/review/page.tsx | 73 +++++++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/app/review/page.tsx b/src/app/review/page.tsx index 7d08cea..062d21c 100644 --- a/src/app/review/page.tsx +++ b/src/app/review/page.tsx @@ -42,40 +42,65 @@ async function hydrate(questionIds: string[]): Promise { .map((r) => r.value); } +async function fetchTabQuestions(nextTab: Tab): Promise { + let questionIds: string[]; + if (nextTab === "wrong") { + questionIds = (await progressRepository.getWrongNotes()).map((n) => n.questionId); + } else if (nextTab === "favorite") { + questionIds = (await progressRepository.getFavorites()).map((n) => n.questionId); + } else { + const attempts = await progressRepository.getAttempts(); + questionIds = getRecentlySolvedQuestionIds(attempts, 20); + } + return hydrate(questionIds); +} + export default function ReviewPage() { const [tab, setTab] = useState("wrong"); const [phase, setPhase] = useState({ kind: "list" }); const [questions, setQuestions] = useState([]); - const [loading, setLoading] = useState(true); + // `loadedTab` (rather than a `loading` boolean flipped via effect) lets `loading` be + // derived during render instead of set synchronously inside useEffect, which + // react-hooks/set-state-in-effect disallows even through an intermediate async call. + const [loadedTab, setLoadedTab] = useState(null); const latestRequestId = useRef(0); + const loading = loadedTab !== tab; - async function loadTab(nextTab: Tab) { + // Reusable for imperative reloads (e.g. the "복습 목록으로" button) — never referenced + // from the effect below, since react-hooks/set-state-in-effect flags any effect that + // captures a function which itself calls a state setter, however deep. + function loadTab(nextTab: Tab) { const requestId = ++latestRequestId.current; - setLoading(true); - try { - let questionIds: string[]; - if (nextTab === "wrong") { - questionIds = (await progressRepository.getWrongNotes()).map((n) => n.questionId); - } else if (nextTab === "favorite") { - questionIds = (await progressRepository.getFavorites()).map((n) => n.questionId); - } else { - const attempts = await progressRepository.getAttempts(); - questionIds = getRecentlySolvedQuestionIds(attempts, 20); + fetchTabQuestions(nextTab).then( + (hydrated) => { + if (requestId !== latestRequestId.current) return; + setQuestions(hydrated); + setLoadedTab(nextTab); + }, + (err) => { + if (requestId !== latestRequestId.current) return; + console.error("loadTab failed:", err); + setQuestions([]); + setLoadedTab(nextTab); } - const hydrated = await hydrate(questionIds); - if (requestId !== latestRequestId.current) return; - setQuestions(hydrated); - } catch { - if (requestId !== latestRequestId.current) return; - setQuestions([]); - } finally { - if (requestId === latestRequestId.current) setLoading(false); - } + ); } useEffect(() => { - void loadTab(tab); - // eslint-disable-next-line react-hooks/exhaustive-deps + const requestId = ++latestRequestId.current; + fetchTabQuestions(tab).then( + (hydrated) => { + if (requestId !== latestRequestId.current) return; + setQuestions(hydrated); + setLoadedTab(tab); + }, + (err) => { + if (requestId !== latestRequestId.current) return; + console.error("loadTab failed:", err); + setQuestions([]); + setLoadedTab(tab); + } + ); }, [tab]); async function handleRemove(questionId: string) { @@ -123,7 +148,7 @@ export default function ReviewPage() { type="button" onClick={() => { setPhase({ kind: "list" }); - void loadTab(tab); + loadTab(tab); }} className="px-4 py-2 rounded bg-blue-600 text-white" > From f846999242b6bed39dd052241ea7a3215e6347b1 Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 01:14:27 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EB=8F=84?= =?UTF-8?q?=EC=A4=91=20=ED=83=AD=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20=ED=83=AD=EC=97=90=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=A0=81=EC=9A=A9=EB=90=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 리뷰 지적(SDD 최종리뷰 minor7과 동일 지점, 두 리뷰어 독립 확인) — handleRemove가 기존 loadTab/effect의 latestRequestId 가드에 참여 안 해서, 삭제 완료 전 탭 전환하면 새 탭의 목록에 필터가 잘못 적용될 수 있었음 --- src/app/review/page.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/review/page.tsx b/src/app/review/page.tsx index 062d21c..61305f5 100644 --- a/src/app/review/page.tsx +++ b/src/app/review/page.tsx @@ -104,11 +104,19 @@ export default function ReviewPage() { }, [tab]); async function handleRemove(questionId: string) { - if (tab === "wrong") { + const removingFromTab = tab; + const requestId = ++latestRequestId.current; + + if (removingFromTab === "wrong") { await progressRepository.removeWrongNote(questionId).catch((err) => console.error(err)); - } else if (tab === "favorite") { + } else if (removingFromTab === "favorite") { await progressRepository.removeFavorite(questionId).catch((err) => console.error(err)); } + + // 삭제가 끝나기 전에 사용자가 다른 탭으로 전환했으면, 지금 화면엔 그 탭의 문항이 + // 떠 있으므로 여기서 필터링하면 안 된다 — DB는 이미 정확히 지워졌고, 화면 반영은 + // 다음 탭 로드(loadTab)가 최신 상태로 알아서 채운다. + if (tab !== removingFromTab || requestId !== latestRequestId.current) return; setQuestions((prev) => prev.filter((q) => q.questionId !== questionId)); } From 4ace3c5cb60d7aa4f4709a35af54679e55055f2d Mon Sep 17 00:00:00 2001 From: ginaseo Date: Fri, 31 Jul 2026 01:17:50 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 지적(SDD 최종리뷰 ledger2와 동일 지점, 두 리뷰어 독립 확인) — removeWrongNote/removeFavorite 실패를 .catch로 삼키고도 무조건 목록에서 필터링하던 것을 실패 시 조기 반환하도록 수정 --- src/app/review/page.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/review/page.tsx b/src/app/review/page.tsx index 61305f5..ac840d9 100644 --- a/src/app/review/page.tsx +++ b/src/app/review/page.tsx @@ -107,10 +107,15 @@ export default function ReviewPage() { const removingFromTab = tab; const requestId = ++latestRequestId.current; - if (removingFromTab === "wrong") { - await progressRepository.removeWrongNote(questionId).catch((err) => console.error(err)); - } else if (removingFromTab === "favorite") { - await progressRepository.removeFavorite(questionId).catch((err) => console.error(err)); + try { + if (removingFromTab === "wrong") { + await progressRepository.removeWrongNote(questionId); + } else if (removingFromTab === "favorite") { + await progressRepository.removeFavorite(questionId); + } + } catch (err) { + console.error("removeWrongNote/removeFavorite failed:", err); + return; // 삭제가 실패했으면 화면에서도 안 지운다 — DB에 그대로 남아있으니 목록도 그래야 맞다. } // 삭제가 끝나기 전에 사용자가 다른 탭으로 전환했으면, 지금 화면엔 그 탭의 문항이