Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
933 changes: 933 additions & 0 deletions docs/superpowers/plans/2026-07-31-review-screen.md

Large diffs are not rendered by default.

20 changes: 14 additions & 6 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ export default function Home() {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-6">
<h1 className="text-2xl font-bold">PassFlow</h1>
<Link
href="/practice"
className="px-6 py-3 rounded bg-blue-600 text-white font-medium"
>
문제풀이 시작 (학습모드)
</Link>
<div className="flex gap-4">
<Link
href="/practice"
className="px-6 py-3 rounded bg-blue-600 text-white font-medium"
>
문제풀이 시작 (학습모드)
</Link>
<Link
href="/review"
className="px-6 py-3 rounded border font-medium"
>
복습
</Link>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/practice/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TheoryMap>),
questionRepository.getTheoryMap(),
]);

const questions = pickRandomQuestions(pool, value.count);
Expand Down
210 changes: 210 additions & 0 deletions src/app/review/page.tsx
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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>
);
}
11 changes: 11 additions & 0 deletions src/features/practice/PracticeSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

toggleFavorite에 해제 경로가 없습니다.

이미 즐겨찾기된 질문은 즉시 반환하고 addFavorite만 호출합니다. ProgressRepositoryremoveFavorite가 있으므로 현재 상태가 true이면 삭제하도록 연결해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/practice/PracticeSession.tsx` around lines 62 - 67, Update
toggleFavorite to support both states: when favorited[current] is true, clear
that entry and call progressRepository.removeFavorite with question.questionId;
otherwise preserve the existing state update and progressRepository.addFavorite
flow.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

즐겨찾기 토글을 추가/삭제 모두 지원하도록 맞춰야 합니다.

현재 상태가 true이면 아무 동작도 하지 않고, UI도 버튼을 비활성화합니다. ProgressRepository.removeFavorite를 사용해 true 상태에서는 삭제하고, 버튼은 처리 중일 때만 비활성화하도록 변경하세요.

  • src/features/practice/PracticeSession.tsx#L62-L67: favorited 상태에 따라 addFavorite 또는 removeFavorite를 호출합니다.
  • src/features/practice/QuestionCard.tsx#L37-L43: disabled 조건을 저장 중 상태로 변경하고 해제 라벨을 제공합니다.
📍 Affects 2 files
  • src/features/practice/PracticeSession.tsx#L62-L67 (this comment)
  • src/features/practice/QuestionCard.tsx#L37-L43
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/practice/PracticeSession.tsx` around lines 62 - 67, Update
toggleFavorite in src/features/practice/PracticeSession.tsx#L62-L67 to call
addFavorite when the current question is not favorited and removeFavorite when
it is, updating state for both outcomes while preserving error handling. In
src/features/practice/QuestionCard.tsx#L37-L43, disable the button only while
the favorite operation is processing and provide an appropriate
remove/unfavorite label when the question is already favorited.

}

useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey) return;
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/features/practice/QuestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ interface QuestionCardProps {
total: number;
selectedAnswer: number | null;
theoryLink: TheoryLink | null;
isFavorited: boolean;
onSelect: (answer: number) => void;
onFavorite: () => void;
}

export function QuestionCard({
Expand All @@ -19,15 +21,27 @@ export function QuestionCard({
total,
selectedAnswer,
theoryLink,
isFavorited,
onSelect,
onFavorite,
}: QuestionCardProps) {
const isGraded = selectedAnswer !== null;
const isCorrect = isGraded && isCorrectOption(question, selectedAnswer);

return (
<div className="max-w-xl mx-auto p-6 flex flex-col gap-4">
<div className="text-sm text-gray-500">
{index + 1} / {total}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
{index + 1} / {total}
</span>
<button
type="button"
onClick={onFavorite}
disabled={isFavorited}
className="text-yellow-600 disabled:text-yellow-600"
>
{isFavorited ? "★ 즐겨찾기 완료" : "☆ 즐겨찾기"}
</button>
</div>

<p className="text-lg font-medium whitespace-pre-wrap">{question.stem}</p>
Expand Down
47 changes: 47 additions & 0 deletions src/features/review/ReviewList.tsx
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>
);
}
Loading