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
62 changes: 62 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useEffect, useState } from "react";
import { IndexedDbProgressRepository } from "@/repositories/ProgressRepository";
import type { DashboardSummary } from "@/types/progress";

const progressRepository = new IndexedDbProgressRepository();

function formatPercent(ratio: number): string {
return `${Math.round(ratio * 100)}%`;
}

export default function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [error, setError] = useState(false);

useEffect(() => {
progressRepository.getDashboardSummary().then(
(result) => setSummary(result),
(err) => {
console.error("getDashboardSummary failed:", err);
setError(true);
}
Comment on lines +20 to +23

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

조회 실패를 빈 학습 기록으로 표시하지 마세요.

getDashboardSummary()가 실패했을 때 EMPTY_SUMMARY를 렌더링하면 IndexedDB 오류가 “풀이 0건, 정답률 0%”로 오인됩니다. 오류 상태를 별도로 저장하고 재시도 또는 오류 안내를 표시해야 합니다.

수정 예시
+  const [loadError, setLoadError] = useState(false);
+
   useEffect(() => {
     progressRepository.getDashboardSummary().then(
       (result) => setSummary(result),
       (err) => {
         console.error("getDashboardSummary failed:", err);
-        setSummary(EMPTY_SUMMARY);
+        setLoadError(true);
       }
     );
   }, []);
 
+  if (loadError) {
+    return <p className="text-center p-10">통계를 불러오지 못했습니다.</p>;
+  }
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(err) => {
console.error("getDashboardSummary failed:", err);
setSummary(EMPTY_SUMMARY);
}
const [loadError, setLoadError] = useState(false);
useEffect(() => {
progressRepository.getDashboardSummary().then(
(result) => setSummary(result),
(err) => {
console.error("getDashboardSummary failed:", err);
setLoadError(true);
}
);
}, []);
if (loadError) {
return <p className="text-center p-10">통계를 불러오지 못했습니다.</p>;
}
🤖 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/app/dashboard/page.tsx` around lines 26 - 29, Update the
getDashboardSummary error handler so failures are stored in a separate error
state instead of replacing the summary with EMPTY_SUMMARY. Render a clear error
message with a retry action when that state is set, while preserving
EMPTY_SUMMARY only for a successful response that contains no learning records.

);
}, []);

if (error) {
return (
<p className="text-center p-10 text-red-700">
학습 기록을 불러오지 못했다. 다시 시도해달라.
</p>
);
}

if (!summary) {
return <p className="text-center p-10">불러오는 중...</p>;
}

return (
<div className="max-w-xl mx-auto p-6 flex flex-col gap-6">
<h1 className="text-xl font-bold">대시보드</h1>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded border flex flex-col gap-1">
<span className="text-sm text-gray-500">오늘 풀이수</span>
<span className="text-2xl font-bold">{summary.todayCount}</span>
</div>
<div className="p-4 rounded border flex flex-col gap-1">
<span className="text-sm text-gray-500">오늘 정답률</span>
<span className="text-2xl font-bold">{formatPercent(summary.todayAccuracy)}</span>
</div>
<div className="p-4 rounded border flex flex-col gap-1">
<span className="text-sm text-gray-500">전체 풀이수</span>
<span className="text-2xl font-bold">{summary.totalCount}</span>
</div>
<div className="p-4 rounded border flex flex-col gap-1">
<span className="text-sm text-gray-500">전체 정답률</span>
<span className="text-2xl font-bold">{formatPercent(summary.totalAccuracy)}</span>
</div>
</div>
</div>
);
}
12 changes: 8 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { NavBar } from "@/features/nav/NavBar";
import "./globals.css";

const geistSans = Geist({
Expand All @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "PassFlow",
description: "정보처리기사 필기 CBT 웹앱",
};

export default function RootLayout({
Expand All @@ -24,10 +25,13 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="ko"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<NavBar />
{children}
</body>
</html>
);
}
7 changes: 4 additions & 3 deletions src/app/review/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,11 @@ export default function ReviewPage() {
setQuestions((prev) => prev.filter((q) => q.questionId !== questionId));
}

async function handleRetryAll() {
async function handleRetry(selectedQuestions: Question[]) {
if (selectedQuestions.length === 0) return;
try {
const theoryMap = await questionRepository.getTheoryMap();
setPhase({ kind: "active", questions, theoryMap });
setPhase({ kind: "active", questions: selectedQuestions, theoryMap });
} catch {
setPhase({ kind: "error", message: "관련 이론 데이터를 불러오지 못했다. 다시 시도해달라." });
}
Expand Down Expand Up @@ -202,7 +203,7 @@ export default function ReviewPage() {
questions={questions}
emptyMessage={EMPTY_MESSAGE[tab]}
onRemove={tab === "recent" ? undefined : handleRemove}
onRetryAll={handleRetryAll}
onRetry={handleRetry}
/>
)}
</div>
Expand Down
30 changes: 30 additions & 0 deletions src/features/nav/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

const LINKS = [
{ href: "/", label: "홈" },
{ href: "/practice", label: "문제풀이" },
{ href: "/review", label: "복습" },
{ href: "/dashboard", label: "대시보드" },
] as const;

export function NavBar() {
const pathname = usePathname();

return (
<nav className="flex gap-4 px-6 py-3 border-b text-sm">
{LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={pathname === link.href ? "font-bold" : "text-gray-500"}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{link.label}
</Link>
))}
</nav>
);
}
4 changes: 3 additions & 1 deletion src/features/practice/PracticeSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export function PracticeSession({ questions, theoryMap, onFinish }: PracticeSess
goTo(current + 1);
} else if (e.key === "ArrowLeft") {
goTo(current - 1);
} else if (e.key === "f" || e.key === "F") {
toggleFavorite();
Comment on lines +86 to +87

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

F 단축키가 실제로 즐겨찾기를 전환하도록 수정하세요.

현재 toggleFavorite()은 이미 선택된 경우 반환하므로 F를 다시 눌러도 해제되지 않습니다. 상태에 따라 addFavorite()removeFavorite()을 분기해 안내 문구의 “F: 즐겨찾기” 동작을 충족해야 합니다.

수정 예시
 function toggleFavorite() {
-  if (favorited[current]) return;
-  setFavorited((prev) => ({ ...prev, [current]: true }));
-  progressRepository
-    .addFavorite(question.questionId)
+  const nextFavorited = !(favorited[current] ?? false);
+  setFavorited((prev) => ({ ...prev, [current]: nextFavorited }));
+  const update = nextFavorited
+    ? progressRepository.addFavorite(question.questionId)
+    : progressRepository.removeFavorite(question.questionId);
+  update
     .catch((err) => console.error("addFavorite failed:", err));
 }

Also applies to: 124-124

🤖 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 86 - 87, Update the
F-key handler in PracticeSession to branch on the current favorite state,
calling addFavorite() when the item is not favorited and removeFavorite() when
it is already favorited; do not call toggleFavorite(), so repeated F presses
both add and remove the favorite as indicated by the shortcut.

}
}
window.addEventListener("keydown", handleKeyDown);
Expand Down Expand Up @@ -119,7 +121,7 @@ export function PracticeSession({ questions, theoryMap, onFinish }: PracticeSess
>
← 이전
</button>
<span>Space: 다음 · 1~4: 답 선택</span>
<span>Space: 다음 · 1~4: 답 선택 · F: 즐겨찾기</span>
{current === questions.length - 1 ? (
<button
type="button"
Expand Down
58 changes: 44 additions & 14 deletions src/features/review/ReviewList.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,65 @@
"use client";

import { useState } from "react";
import type { Question } from "@/types/question";

interface ReviewListProps {
questions: Question[];
emptyMessage: string;
onRemove?: (questionId: string) => void;
onRetryAll: () => void;
onRetry: (questions: Question[]) => void;
}

export function ReviewList({ questions, emptyMessage, onRemove, onRetryAll }: ReviewListProps) {
export function ReviewList({ questions, emptyMessage, onRemove, onRetry }: ReviewListProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());

if (questions.length === 0) {
return <p className="text-center text-gray-500 p-10">{emptyMessage}</p>;
}

function toggleSelected(questionId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(questionId)) {
next.delete(questionId);
} else {
next.add(questionId);
}
return next;
});
}

const selectedQuestions = questions.filter((q) => selected.has(q.questionId));

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>
<div className="flex gap-2">
<button
type="button"
onClick={() => onRetry(questions)}
className="px-4 py-2 rounded bg-blue-600 text-white font-medium"
>
전체 다시 풀기 ({questions.length}문제)
</button>
<button
type="button"
onClick={() => onRetry(selectedQuestions)}
disabled={selectedQuestions.length === 0}
className="px-4 py-2 rounded border font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
선택 다시 풀기 ({selectedQuestions.length}문제)
</button>
</div>
<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>
<li key={question.questionId} className="flex items-center gap-3 p-3 rounded border">
<input
type="checkbox"
checked={selected.has(question.questionId)}
onChange={() => toggleSelected(question.questionId)}
aria-label={`${question.stem} 선택`}
/>
<span className="text-sm truncate flex-1">{question.stem}</span>
{onRemove && (
<button
type="button"
Expand Down