-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 복습 화면 (오답노트/즐겨찾기/최근 푼 문제) #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06fca8d
ee66fa8
eb9068c
f973d8f
3ecfe2d
2690073
1dccf2e
6bb3d06
f846999
4ace3c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useRef, 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<Tab, string> = { | ||
| wrong: "오답노트", | ||
| favorite: "즐겨찾기", | ||
| recent: "최근 푼 문제", | ||
| }; | ||
|
|
||
| const EMPTY_MESSAGE: Record<Tab, string> = { | ||
| wrong: "오답노트가 비어있다.", | ||
| favorite: "즐겨찾기한 문제가 없다.", | ||
| recent: "최근 푼 문제가 없다.", | ||
| }; | ||
|
|
||
| async function hydrate(questionIds: string[]): Promise<Question[]> { | ||
| const results = await Promise.allSettled( | ||
| questionIds.map((id) => questionRepository.getQuestion(id)) | ||
| ); | ||
| return results | ||
| .filter((r): r is PromiseFulfilledResult<Question> => r.status === "fulfilled") | ||
| .map((r) => r.value); | ||
| } | ||
|
|
||
| async function fetchTabQuestions(nextTab: Tab): Promise<Question[]> { | ||
| 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<Tab>("wrong"); | ||
| const [phase, setPhase] = useState<Phase>({ kind: "list" }); | ||
| const [questions, setQuestions] = useState<Question[]>([]); | ||
| // `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<Tab | null>(null); | ||
| const latestRequestId = useRef(0); | ||
| const loading = loadedTab !== 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; | ||
| 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); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| useEffect(() => { | ||
| 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) { | ||
| const removingFromTab = tab; | ||
| const requestId = ++latestRequestId.current; | ||
|
|
||
| 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에 그대로 남아있으니 목록도 그래야 맞다. | ||
| } | ||
|
|
||
| // 삭제가 끝나기 전에 사용자가 다른 탭으로 전환했으면, 지금 화면엔 그 탭의 문항이 | ||
| // 떠 있으므로 여기서 필터링하면 안 된다 — DB는 이미 정확히 지워졌고, 화면 반영은 | ||
| // 다음 탭 로드(loadTab)가 최신 상태로 알아서 채운다. | ||
| if (tab !== removingFromTab || requestId !== latestRequestId.current) return; | ||
| 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 ( | ||
| <div className="text-center p-10 flex flex-col gap-4 items-center"> | ||
| <p className="text-lg font-medium text-red-700">{phase.message}</p> | ||
| <button | ||
| type="button" | ||
| onClick={() => setPhase({ kind: "list" })} | ||
| className="px-4 py-2 rounded bg-blue-600 text-white" | ||
| > | ||
| 목록으로 | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (phase.kind === "done") { | ||
| const { total, solved, correct, wrong } = phase.summary; | ||
| return ( | ||
| <div className="text-center p-10 flex flex-col gap-4 items-center"> | ||
| <p className="text-lg font-medium">복습 완료.</p> | ||
| <p className="text-gray-600"> | ||
| {total}문제 중 {solved}문제 풀이 — 정답 {correct} · 오답 {wrong} | ||
| </p> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setPhase({ kind: "list" }); | ||
| loadTab(tab); | ||
| }} | ||
| className="px-4 py-2 rounded bg-blue-600 text-white" | ||
| > | ||
| 복습 목록으로 | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (phase.kind === "active") { | ||
| return ( | ||
| <PracticeSession | ||
| questions={phase.questions} | ||
| theoryMap={phase.theoryMap} | ||
| onFinish={(summary) => setPhase({ kind: "done", summary })} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-4"> | ||
| <div className="max-w-xl mx-auto w-full flex gap-2 px-6 pt-6"> | ||
| {(["wrong", "favorite", "recent"] as const).map((t) => ( | ||
| <button | ||
| key={t} | ||
| type="button" | ||
| onClick={() => setTab(t)} | ||
| className={`px-3 py-1.5 rounded border ${tab === t ? "bg-black text-white" : ""}`} | ||
| > | ||
| {TAB_LABEL[t]} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| {loading ? ( | ||
| <p className="text-center p-10">불러오는 중...</p> | ||
| ) : ( | ||
| <ReviewList | ||
| questions={questions} | ||
| emptyMessage={EMPTY_MESSAGE[tab]} | ||
| onRemove={tab === "recent" ? undefined : handleRemove} | ||
| onRetryAll={handleRetryAll} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ const progressRepository = new IndexedDbProgressRepository(); | |
| export function PracticeSession({ questions, theoryMap, onFinish }: PracticeSessionProps) { | ||
| const [current, setCurrent] = useState(0); | ||
| const [answers, setAnswers] = useState<Record<number, number>>({}); | ||
| const [favorited, setFavorited] = useState<Record<number, boolean>>({}); | ||
| 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)); | ||
|
Comment on lines
+62
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
이미 즐겨찾기된 질문은 즉시 반환하고 🤖 Prompt for AI Agents🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 즐겨찾기 토글을 추가/삭제 모두 지원하도록 맞춰야 합니다. 현재 상태가 true이면 아무 동작도 하지 않고, UI도 버튼을 비활성화합니다.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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} | ||
| /> | ||
| <div className="max-w-xl mx-auto w-full flex justify-between px-6 text-sm text-gray-500"> | ||
| <button | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <p className="text-center text-gray-500 p-10">{emptyMessage}</p>; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="max-w-xl mx-auto p-6 flex flex-col gap-4"> | ||
| <button | ||
| type="button" | ||
| onClick={onRetryAll} | ||
| className="self-start px-4 py-2 rounded bg-blue-600 text-white font-medium" | ||
| > | ||
| 전체 다시 풀기 ({questions.length}문제) | ||
| </button> | ||
| <ul className="flex flex-col gap-2"> | ||
| {questions.map((question) => ( | ||
| <li | ||
| key={question.questionId} | ||
| className="flex items-center justify-between gap-3 p-3 rounded border" | ||
| > | ||
| <span className="text-sm truncate">{question.stem}</span> | ||
| {onRemove && ( | ||
| <button | ||
| type="button" | ||
| onClick={() => onRemove(question.questionId)} | ||
| className="text-sm text-gray-500 shrink-0" | ||
| > | ||
| 제거 | ||
| </button> | ||
| )} | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.