From cea62482336a416dae66a9808627e8dbfbafe8a2 Mon Sep 17 00:00:00 2001 From: eil-you Date: Thu, 13 Aug 2026 20:26:05 +0900 Subject: [PATCH] =?UTF-8?q?chore(app):=20=EB=82=B4=EA=B0=80=20=ED=91=BC=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=9C=EA=B1=B0=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/app/history/records/page.tsx | 12 - .../features/history/attempt-history-logic.ts | 63 ----- .../components/attempt-history-page.tsx | 224 ------------------ .../history/components/history-page.tsx | 13 - app/src/lib/api/index.ts | 3 - app/src/lib/api/quiz.ts | 33 +-- app/src/test/attempt-history-logic.test.ts | 73 ------ app/src/test/attempt-history-page.test.tsx | 128 ---------- 8 files changed, 1 insertion(+), 548 deletions(-) delete mode 100644 app/src/app/history/records/page.tsx delete mode 100644 app/src/features/history/attempt-history-logic.ts delete mode 100644 app/src/features/history/components/attempt-history-page.tsx delete mode 100644 app/src/test/attempt-history-logic.test.ts delete mode 100644 app/src/test/attempt-history-page.test.tsx diff --git a/app/src/app/history/records/page.tsx b/app/src/app/history/records/page.tsx deleted file mode 100644 index 7916eca..0000000 --- a/app/src/app/history/records/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { RequireAuth } from "@/features/auth/require-auth"; -import { AttemptHistoryPage } from "@/features/history/components/attempt-history-page"; - -export const dynamic = "force-dynamic"; - -export default function HistoryRecords() { - return ( - - - - ); -} diff --git a/app/src/features/history/attempt-history-logic.ts b/app/src/features/history/attempt-history-logic.ts deleted file mode 100644 index d76a2b4..0000000 --- a/app/src/features/history/attempt-history-logic.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { QuizAttemptHistoryItem } from "@/lib/api/quiz"; - -const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul" }); // en-CA → YYYY-MM-DD -const timeFormatter = new Intl.DateTimeFormat("ko-KR", { - hour: "numeric", - minute: "2-digit", - timeZone: "Asia/Seoul", -}); - -function dayKey(date: Date): string { - return dayKeyFormatter.format(date); -} - -/** 풀이 시각을 "오늘"·"어제"·"n월 n일"로 — 목록의 날짜 구분선 라벨. */ -export function formatAttemptDayLabel(submittedAt: string, now: Date): string { - const submitted = new Date(submittedAt); - const submittedKey = dayKey(submitted); - if (submittedKey === dayKey(now)) { - return "오늘"; - } - - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - if (submittedKey === dayKey(yesterday)) { - return "어제"; - } - - return submitted.toLocaleDateString("ko-KR", { - month: "long", - day: "numeric", - timeZone: "Asia/Seoul", - }); -} - -/** 풀이 시각을 "오후 3:42" 형식으로. */ -export function formatAttemptTime(submittedAt: string): string { - return timeFormatter.format(new Date(submittedAt)); -} - -export type AttemptDayGroup = { - dayLabel: string; - items: QuizAttemptHistoryItem[]; -}; - -/** - * 최신순으로 이미 정렬된 목록을 날짜 구분선 기준으로 묶는다. API가 항상 최신순으로 주므로 - * 같은 날짜는 연속해서 나타난다 — 재정렬 없이 순서대로 훑으며 묶기만 하면 된다. - */ -export function groupAttemptsByDay(items: QuizAttemptHistoryItem[], now: Date): AttemptDayGroup[] { - const groups: AttemptDayGroup[] = []; - - for (const item of items) { - const dayLabel = formatAttemptDayLabel(item.submittedAt, now); - const lastGroup = groups[groups.length - 1]; - if (lastGroup && lastGroup.dayLabel === dayLabel) { - lastGroup.items.push(item); - } else { - groups.push({ dayLabel, items: [item] }); - } - } - - return groups; -} diff --git a/app/src/features/history/components/attempt-history-page.tsx b/app/src/features/history/components/attempt-history-page.tsx deleted file mode 100644 index 7f9d56d..0000000 --- a/app/src/features/history/components/attempt-history-page.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Chip } from "@/components/ui/chip"; -import { EmptyState } from "@/components/ui/empty-state"; -import { Feedback } from "@/components/ui/feedback"; -import { Skeleton } from "@/components/ui/skeleton"; -import { formatAttemptTime, groupAttemptsByDay } from "@/features/history/attempt-history-logic"; -import { getPlayQuestionKindLabel, isUnauthorized } from "@/features/play/quiz-shared"; -import { getAttemptHistory, type QuizAttemptHistoryItem } from "@/lib/api"; - -const PAGE_SIZE = 20; - -export function AttemptHistoryPage() { - const router = useRouter(); - const [items, setItems] = useState([]); - const [cursor, setCursor] = useState(null); - const [hasNext, setHasNext] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [reloadKey, setReloadKey] = useState(0); - const sentinelRef = useRef(null); - - useEffect(() => { - let ignore = false; - const requestKey = reloadKey; - - if (requestKey < 0) { - return undefined; - } - - async function load() { - setIsLoading(true); - setError(null); - - try { - const response = await getAttemptHistory(null, PAGE_SIZE); - if (!ignore) { - setItems(response.data.items); - setHasNext(response.meta?.hasNext ?? false); - setCursor(response.meta?.nextCursor ?? null); - } - } catch (loadError) { - if (isUnauthorized(loadError)) { - router.replace("/login"); - return; - } - - if (!ignore) { - setError("풀이 기록을 불러오지 못했어요."); - } - } finally { - if (!ignore) { - setIsLoading(false); - } - } - } - - void load(); - - return () => { - ignore = true; - }; - }, [reloadKey, router]); - - const loadMore = useCallback(async () => { - if (!hasNext || isLoadingMore || cursor === null) { - return; - } - - setIsLoadingMore(true); - try { - const response = await getAttemptHistory(cursor, PAGE_SIZE); - setItems((current) => [...current, ...response.data.items]); - setHasNext(response.meta?.hasNext ?? false); - setCursor(response.meta?.nextCursor ?? null); - } catch (loadError) { - if (isUnauthorized(loadError)) { - router.replace("/login"); - return; - } - // 다음 페이지 실패는 목록을 지우지 않고 조용히 중단 — 사용자는 스크롤을 멈추면 그만이다. - } finally { - setIsLoadingMore(false); - } - }, [cursor, hasNext, isLoadingMore, router]); - - useEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel || !hasNext) { - return undefined; - } - - const observer = new IntersectionObserver((entries) => { - if (entries[0]?.isIntersecting) { - void loadMore(); - } - }); - observer.observe(sentinel); - - return () => observer.disconnect(); - }, [hasNext, loadMore]); - - const now = new Date(); - const groups = groupAttemptsByDay(items, now); - - const liveText = isLoading - ? "풀이 기록을 불러오는 중" - : error - ? error - : items.length === 0 - ? "아직 푼 문제가 없어요" - : `풀이 기록 ${items.length}건`; - - return ( -
-
-
- - ‹ - -
-

히스토리

-

- 내가 푼 문제 -

- {!isLoading && !error ? ( -

- 지금까지 {items.length}문제 풀었어요 -

- ) : null} -
-
- -

- {liveText} -

- - {isLoading ? : null} - - {!isLoading && error ? ( - setReloadKey((key) => key + 1)} tone="error"> - {error} - - ) : null} - - {!isLoading && !error && items.length === 0 ? ( - - ) : null} - - {!isLoading && !error && items.length > 0 ? ( -
- {groups.map((group) => ( -
-

{group.dayLabel}

- {group.items.map((item) => ( - - ))} -
- ))} - - {hasNext ? ( -
-
- ) : null} -
- ) : null} -
-
- ); -} - -function AttemptCard({ item }: { item: QuizAttemptHistoryItem }) { - return ( -
-
- - {getPlayQuestionKindLabel(item.type)} - - - {item.isCorrect ? "✓ 정답" : "✕ 오답"} - -
-

{item.questionText}

- {item.selectedAnswer !== null ? ( -
- 내가 고른 답 - - {item.selectedAnswer} - -
- ) : null} -
- ); -} - -function AttemptHistorySkeleton() { - return ( -
- {[0, 1, 2, 3].map((row) => ( - - ))} -
- ); -} diff --git a/app/src/features/history/components/history-page.tsx b/app/src/features/history/components/history-page.tsx index 56ca409..e274c06 100644 --- a/app/src/features/history/components/history-page.tsx +++ b/app/src/features/history/components/history-page.tsx @@ -101,19 +101,6 @@ export function HistoryPage() { - - - 내가 푼 문제 - - 지금까지 풀어본 문제와 답을 확인해요 - - - - -

{liveText}

diff --git a/app/src/lib/api/index.ts b/app/src/lib/api/index.ts index d77c3fd..5e77a76 100644 --- a/app/src/lib/api/index.ts +++ b/app/src/lib/api/index.ts @@ -27,14 +27,11 @@ export { type AnswerSubmitResponse, type CompletedStep, type CompletedStepsResponse, - getAttemptHistory, getCompletedSteps, getNextQuiz, getQuizExplanation, getStepQuiz, type Highlight, - type QuizAttemptHistoryItem, - type QuizAttemptHistoryResponse, type QuizChoice, type QuizDifficulty, type QuizExplanationResponse, diff --git a/app/src/lib/api/quiz.ts b/app/src/lib/api/quiz.ts index d975aac..fb56bfa 100644 --- a/app/src/lib/api/quiz.ts +++ b/app/src/lib/api/quiz.ts @@ -1,4 +1,4 @@ -import { apiRequest, apiRequestWithMeta, type CursorMeta } from "./client"; +import { apiRequest } from "./client"; export type QuizType = "OX" | "MULTIPLE_CHOICE" | "KEYWORD_BLANK"; export type QuizDifficulty = "EASY" | "MEDIUM" | "HARD"; @@ -99,25 +99,6 @@ export type CompletedStepsResponse = { steps: CompletedStep[]; }; -/** - * 유저가 지금까지 제출한 풀이 시도 1건. `selectedAnswer`는 서버가 이미 사람이 읽을 문구로 - * 변환해서 준다(사지선다는 선택지 텍스트, 빈칸은 쉼표로 이어붙인 값) — 프론트는 타입별 분기 없이 - * 그대로 표시하면 된다. 이 필드가 도입되기 전 기록은 null. - */ -export type QuizAttemptHistoryItem = { - attemptId: number; - quizId: number; - type: QuizType; - questionText: string; - selectedAnswer: string | null; - isCorrect: boolean; - submittedAt: string; -}; - -export type QuizAttemptHistoryResponse = { - items: QuizAttemptHistoryItem[]; -}; - /** courseId 생략 시 서버가 기본 코스를 쓴다(코스 탭에서 코스를 지정해 진입할 때만 넘긴다). */ export function getNextQuiz(courseId?: number): Promise { return apiRequest( @@ -154,15 +135,3 @@ export function requestQuizHint(quizId: number): Promise { export function getQuizExplanation(quizId: number): Promise { return apiRequest(`/quizzes/${quizId}/explanation`); } - -/** 내가 푼 문제 히스토리(이슈 191) — 최신순 커서 페이지네이션. */ -export function getAttemptHistory( - cursor: string | null, - size?: number, -): Promise<{ data: QuizAttemptHistoryResponse; meta: CursorMeta | null }> { - const query = new URLSearchParams(); - if (cursor) query.set("cursor", cursor); - if (size) query.set("size", String(size)); - const qs = query.toString(); - return apiRequestWithMeta(`/quizzes/attempts${qs ? `?${qs}` : ""}`); -} diff --git a/app/src/test/attempt-history-logic.test.ts b/app/src/test/attempt-history-logic.test.ts deleted file mode 100644 index 2328fc5..0000000 --- a/app/src/test/attempt-history-logic.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatAttemptDayLabel, - formatAttemptTime, - groupAttemptsByDay, -} from "@/features/history/attempt-history-logic"; -import type { QuizAttemptHistoryItem } from "@/lib/api/quiz"; - -const NOW = new Date("2026-08-03T10:00:00+09:00"); - -function item(overrides: Partial): QuizAttemptHistoryItem { - return { - attemptId: 1, - quizId: 1, - type: "OX", - questionText: "문제", - selectedAnswer: "O", - isCorrect: true, - submittedAt: "2026-08-03T09:00:00+09:00", - ...overrides, - }; -} - -describe("formatAttemptDayLabel", () => { - it("같은 날이면 '오늘'을 반환한다", () => { - expect(formatAttemptDayLabel("2026-08-03T09:00:00+09:00", NOW)).toBe("오늘"); - }); - - it("하루 전이면 '어제'를 반환한다", () => { - expect(formatAttemptDayLabel("2026-08-02T23:59:00+09:00", NOW)).toBe("어제"); - }); - - it("이틀 이상 전이면 'n월 n일'로 반환한다", () => { - expect(formatAttemptDayLabel("2026-07-30T09:00:00+09:00", NOW)).toBe("7월 30일"); - }); - - it("자정 근처 KST 경계도 정확히 구분한다", () => { - // UTC로는 8/2 15:30이지만 KST로는 8/3 00:30 — '오늘'이어야 한다 - expect(formatAttemptDayLabel("2026-08-02T15:30:00Z", NOW)).toBe("오늘"); - }); -}); - -describe("formatAttemptTime", () => { - it("오후 h:mm 형식으로 반환한다", () => { - expect(formatAttemptTime("2026-08-03T15:05:00+09:00")).toBe("오후 3:05"); - }); - - it("오전 h:mm 형식으로 반환한다", () => { - expect(formatAttemptTime("2026-08-03T09:05:00+09:00")).toBe("오전 9:05"); - }); -}); - -describe("groupAttemptsByDay", () => { - it("같은 날짜의 연속된 항목을 하나의 그룹으로 묶는다", () => { - const items = [ - item({ attemptId: 3, submittedAt: "2026-08-03T09:30:00+09:00" }), - item({ attemptId: 2, submittedAt: "2026-08-03T09:00:00+09:00" }), - item({ attemptId: 1, submittedAt: "2026-08-02T20:00:00+09:00" }), - ]; - - const groups = groupAttemptsByDay(items, NOW); - - expect(groups).toHaveLength(2); - expect(groups[0]?.dayLabel).toBe("오늘"); - expect(groups[0]?.items).toHaveLength(2); - expect(groups[1]?.dayLabel).toBe("어제"); - expect(groups[1]?.items).toHaveLength(1); - }); - - it("빈 목록은 빈 그룹 배열을 반환한다", () => { - expect(groupAttemptsByDay([], NOW)).toEqual([]); - }); -}); diff --git a/app/src/test/attempt-history-page.test.tsx b/app/src/test/attempt-history-page.test.tsx deleted file mode 100644 index 21423a8..0000000 --- a/app/src/test/attempt-history-page.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AttemptHistoryPage } from "@/features/history/components/attempt-history-page"; -import { ApiError, getAttemptHistory } from "@/lib/api"; - -const mockRouter = vi.hoisted(() => ({ - push: vi.fn(), - replace: vi.fn(), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => mockRouter, -})); - -vi.mock("@/lib/api", async () => { - const actual = await vi.importActual("@/lib/api"); - return { ...actual, getAttemptHistory: vi.fn() }; -}); - -describe("AttemptHistoryPage", () => { - beforeEach(() => { - vi.mocked(getAttemptHistory).mockReset(); - mockRouter.push.mockReset(); - mockRouter.replace.mockReset(); - }); - - it("풀이 기록을 문제·답·정오·유형과 함께 보여준다", async () => { - vi.mocked(getAttemptHistory).mockResolvedValue({ - data: { - items: [ - { - attemptId: 2, - quizId: 20, - type: "MULTIPLE_CHOICE", - questionText: "다음 코드의 시간복잡도는?", - selectedAnswer: "O(n)", - isCorrect: false, - submittedAt: "2026-08-03T09:00:00+09:00", - }, - { - attemptId: 1, - quizId: 10, - type: "OX", - questionText: "TCP는 연결 지향 프로토콜이다.", - selectedAnswer: "O", - isCorrect: true, - submittedAt: "2026-08-03T08:00:00+09:00", - }, - ], - }, - meta: { hasNext: false, nextCursor: null }, - }); - - render(); - - expect(await screen.findByText("다음 코드의 시간복잡도는?")).toBeInTheDocument(); - expect(screen.getByText("O(n)")).toBeInTheDocument(); - expect(screen.getByText("✕ 오답")).toBeInTheDocument(); - expect(screen.getByText("TCP는 연결 지향 프로토콜이다.")).toBeInTheDocument(); - expect(screen.getByText("✓ 정답")).toBeInTheDocument(); - expect(screen.getByText("사지선다")).toBeInTheDocument(); - expect(screen.getByText("OX")).toBeInTheDocument(); - expect(screen.getByText("지금까지 2문제 풀었어요")).toBeInTheDocument(); - }); - - it("아직 푼 문제가 없으면 빈 상태를 보여준다", async () => { - vi.mocked(getAttemptHistory).mockResolvedValue({ - data: { items: [] }, - meta: { hasNext: false, nextCursor: null }, - }); - - render(); - - // sr-only 라이브 리전이 같은 문구를 한 번 더 담고 있어(접근성 목적) getAllByText로 확인한다. - expect(await screen.findAllByText("아직 푼 문제가 없어요")).not.toHaveLength(0); - }); - - it("다음 페이지가 있으면 더 불러오는 중 표시를 보여준다", async () => { - vi.mocked(getAttemptHistory).mockResolvedValue({ - data: { - items: [ - { - attemptId: 1, - quizId: 10, - type: "OX", - questionText: "TCP는 연결 지향 프로토콜이다.", - selectedAnswer: "O", - isCorrect: true, - submittedAt: "2026-08-03T08:00:00+09:00", - }, - ], - }, - meta: { hasNext: true, nextCursor: "cursor-1" }, - }); - - render(); - - expect(await screen.findByText("더 불러오는 중")).toBeInTheDocument(); - }); - - it("불러오기 실패 시 에러와 재시도 버튼을 보여주고 재시도하면 다시 불러온다", async () => { - vi.mocked(getAttemptHistory).mockRejectedValueOnce(new Error("network")); - - render(); - - expect(await screen.findByRole("status")).toHaveTextContent("풀이 기록을 불러오지 못했어요."); - - vi.mocked(getAttemptHistory).mockResolvedValueOnce({ - data: { items: [] }, - meta: { hasNext: false, nextCursor: null }, - }); - fireEvent.click(screen.getByRole("button", { name: "재시도" })); - - expect(await screen.findAllByText("아직 푼 문제가 없어요")).not.toHaveLength(0); - }); - - it("인증이 만료됐으면 로그인 화면으로 보낸다", async () => { - vi.mocked(getAttemptHistory).mockRejectedValue( - new ApiError({ code: "UNAUTHORIZED", status: 401, message: "세션이 만료됐어요." }), - ); - - render(); - - await waitFor(() => { - expect(mockRouter.replace).toHaveBeenCalledWith("/login"); - }); - }); -});