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
2 changes: 1 addition & 1 deletion frontend/src/features/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ features/{X} → pages/*, app/* ✗
| `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 |
| `repo` | GitHub 후보 조회/등록/목록/삭제 | US-07, US-08 |
| `analysis` | 분석 문서 목록·요약·기술스택·원문(presigned) | US-11, US-12 |
| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 |
| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 + 복습 드릴 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 |
| `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 |
| `history` (계획) | 세션 히스토리 목록·상세, 통계 | US-15, US-16, US-26, US-27 |

Expand Down
95 changes: 95 additions & 0 deletions frontend/src/features/interview/ui/BookmarkDrill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BookmarkDrill } from './BookmarkDrill'
import type { BookmarkedQuestion } from '../api/bookmarkApi'

const items: BookmarkedQuestion[] = [
{
messageId: 100,
sessionId: 7,
sessionTitle: '백엔드 모의면접',
category: 'CS_FUNDAMENTAL',
question: 'ACID 를 설명해 주세요.',
expectedSignal: '4대 속성을 예시와 함께',
myAnswer: '원자성만 말했습니다',
modelAnswer: '원자성·일관성·격리성·지속성',
coachingComment: '나머지도 짚어보세요',
createdAt: '2026-08-18T00:00:00Z',
},
{
messageId: 101,
sessionId: 7,
sessionTitle: '백엔드 모의면접',
category: 'TECH_CHOICE',
question: '왜 Kafka 를 골랐나요?',
createdAt: '2026-08-18T00:00:00Z',
},
]

beforeEach(() => window.localStorage.clear())

describe('BookmarkDrill', () => {
// 드릴의 핵심 — 먼저 답해 보고 그 다음에 정답을 본다.
it('정답 확인 전에는 모범 답안이 보이지 않는다', async () => {
render(<BookmarkDrill items={items} onExit={vi.fn()} />)

expect(screen.getByText('ACID 를 설명해 주세요.')).toBeInTheDocument()
expect(screen.queryByText('원자성·일관성·격리성·지속성')).not.toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))

expect(screen.getByText('원자성·일관성·격리성·지속성')).toBeInTheDocument()
expect(screen.getByText('원자성만 말했습니다')).toBeInTheDocument()
})

it('다음 질문으로 넘어가면 정답이 다시 가려진다', async () => {
render(<BookmarkDrill items={items} onExit={vi.fn()} />)

await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))
await userEvent.click(screen.getByRole('button', { name: '다음 질문' }))

expect(screen.getByText('왜 Kafka 를 골랐나요?')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '정답 확인' })).toBeInTheDocument()
})

// 기록이 없는 질문도 드릴에 포함된다 — 펼쳤을 때 이유를 알려준다.
it('복습 재료가 없는 질문은 이유를 보여준다', async () => {
render(<BookmarkDrill items={[items[1]]} onExit={vi.fn()} />)

await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))

expect(
screen.getByText('이 질문에는 아직 답변·피드백 기록이 없어요.'),
).toBeInTheDocument()
})

it('마지막 질문을 마치면 완료 화면을 보여준다', async () => {
render(<BookmarkDrill items={[items[0]]} onExit={vi.fn()} />)

await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))
await userEvent.click(screen.getByRole('button', { name: '복습 마치기' }))

expect(screen.getByText('복습을 마쳤습니다')).toBeInTheDocument()
})

it('목록으로 나갈 수 있다', async () => {
const onExit = vi.fn()
render(<BookmarkDrill items={items} onExit={onExit} />)

await userEvent.click(screen.getByRole('button', { name: '목록으로' }))

expect(onExit).toHaveBeenCalled()
})

// 적어둔 답은 다시 들어와도 남아 있어야 한다.
it('적은 답변이 재진입 후에도 남는다', async () => {
const { unmount } = render(<BookmarkDrill items={items} onExit={vi.fn()} />)
await userEvent.type(screen.getByLabelText(/다시 답해 보기/), '지금이라면 이렇게')
unmount()

render(<BookmarkDrill items={items} onExit={vi.fn()} />)

expect(screen.getByLabelText(/다시 답해 보기/)).toHaveValue('지금이라면 이렇게')
})
})
145 changes: 145 additions & 0 deletions frontend/src/features/interview/ui/BookmarkDrill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useMemo } from 'react'
import { Button } from '@/shared/ui/Button'
import { TextArea } from '@/shared/ui/TextArea'
import { Eyebrow, StatusBadge } from '@/shared/ui'
import { useQuestionRunner } from '@/shared/hooks'
import { categoryLabel } from '../lib/categoryLabel'
import type { BookmarkedQuestion } from '../api/bookmarkApi'

const STORAGE_KEY = 'stackup:bookmark-drill-answers'

/**
* 오답노트 복습 드릴 — 한 문제씩 다시 답해 보고 모범 답안과 비교한다.
* 연습 면접과 같은 상태 기계(`useQuestionRunner`)를 쓴다.
*/
export function BookmarkDrill({
items,
onExit,
}: {
items: BookmarkedQuestion[]
onExit: () => void
}) {
const ids = useMemo(() => items.map((i) => String(i.messageId)), [items])
const { index, total, isLast, done, revealed, answers, reveal, next, setAnswer, reset } =
useQuestionRunner(ids, STORAGE_KEY)

const current = items[index]

if (done || !current) {
return (
<div className="flex flex-col items-center gap-5 py-16 text-center">
<p className="font-sans text-[20px] font-bold tracking-[-0.02em] text-fg">
복습을 마쳤습니다
</p>
<p className="text-body font-normal text-fg-muted">
담아둔 질문 {total}개를 모두 다시 풀었어요.
</p>
<div className="flex gap-3">
<Button onClick={reset}>처음부터 다시</Button>
<Button variant="secondary" onClick={onExit}>
목록으로
</Button>
</div>
</div>
)
}

const id = String(current.messageId)
const label = categoryLabel(current.category)

return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between gap-3">
<p className="font-mono text-caption tracking-tight text-fg-subtle">
{index + 1} / {total}
</p>
<Button variant="ghost" size="sm" onClick={onExit}>
목록으로
</Button>
</div>

<div className="flex flex-wrap items-center gap-2">
{label && <StatusBadge tone="info">{label}</StatusBadge>}
{current.sessionTitle && (
<span className="truncate text-caption text-fg-subtle">
{current.sessionTitle}
</span>
)}
</div>

<p
className="font-sans text-[20px] font-bold text-fg sm:text-[22px]"
style={{ lineHeight: 1.45, letterSpacing: '-0.03em', wordBreak: 'keep-all' }}
>
{current.question}
</p>
{current.expectedSignal && (
<p className="text-caption text-fg-muted">평가 관점: {current.expectedSignal}</p>
)}

<div className="flex flex-col gap-2">
<label className="text-caption text-fg-subtle" htmlFor="drill-answer">
다시 답해 보기 — 적어 본 뒤 그때 답변·모범 답안과 비교하세요.
</label>
<TextArea
id="drill-answer"
value={answers[id] ?? ''}
onChange={(v) => setAnswer(id, v)}
rows={4}
placeholder="지금이라면 어떻게 답하시겠어요?"
/>
</div>

{revealed && (
<div className="flex flex-col gap-3">
{current.myAnswer && (
<Panel title="그때 내 답변" body={current.myAnswer} tone="muted" />
)}
{current.modelAnswer && <Panel title="모범 답안" body={current.modelAnswer} />}
{current.coachingComment && (
<Panel title="코칭" body={current.coachingComment} tone="muted" />
)}
{!current.myAnswer && !current.modelAnswer && !current.coachingComment && (
<p className="text-caption text-fg-subtle">
이 질문에는 아직 답변·피드백 기록이 없어요.
</p>
)}
</div>
)}

<div className="flex justify-end gap-3 border-t border-border pt-4">
{revealed ? (
<Button onClick={next}>{isLast ? '복습 마치기' : '다음 질문'}</Button>
) : (
<Button variant="secondary" onClick={reveal}>
정답 확인
</Button>
)}
</div>
</div>
)
}

function Panel({
title,
body,
tone = 'strong',
}: {
title: string
body: string
tone?: 'strong' | 'muted'
}) {
return (
<div className="rounded-lg border border-border bg-surface px-3 py-2.5">
<Eyebrow className="mb-1">{title}</Eyebrow>
<p
className={[
'whitespace-pre-wrap text-body font-normal leading-relaxed',
tone === 'strong' ? 'text-fg-strong' : 'text-fg-muted',
].join(' ')}
>
{body}
</p>
</div>
)
}
25 changes: 20 additions & 5 deletions frontend/src/features/interview/ui/BookmarkList.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { EmptyState, ListSkeleton, QueryError, StatusBadge } from '@/shared/ui'
import { Button } from '@/shared/ui/Button'
import { categoryLabel } from '../lib/categoryLabel'
import { useBookmarks, useSetQuestionBookmark } from '../model/useBookmarks'
import type { BookmarkedQuestion } from '../api/bookmarkApi'
import { BookmarkDrill } from './BookmarkDrill'

export function BookmarkList() {
const { data = [], isPending, isError, refetch } = useBookmarks()
const [drilling, setDrilling] = useState(false)

if (isPending) {
return <ListSkeleton label="오답노트를 불러오는 중…" />
Expand All @@ -25,12 +28,24 @@ export function BookmarkList() {
)
}

if (drilling) {
return <BookmarkDrill items={data} onExit={() => setDrilling(false)} />
}

return (
<ul className="flex flex-col gap-3">
{data.map((item) => (
<BookmarkCard key={item.messageId} item={item} />
))}
</ul>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-caption text-fg-subtle">담아둔 질문 {data.length}개</p>
<Button size="sm" onClick={() => setDrilling(true)}>
복습 시작
</Button>
</div>
<ul className="flex flex-col gap-3">
{data.map((item) => (
<BookmarkCard key={item.messageId} item={item} />
))}
</ul>
</div>
)
}

Expand Down
50 changes: 18 additions & 32 deletions frontend/src/features/practice/model/usePracticeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { useQuery } from '@tanstack/react-query'
import { selectQuestions } from '@/domain/practice'
import type { PracticeTrack } from '@/domain/practice'
import { loadQuestionBank } from '../api/loadQuestionBank'
import { useQuestionRunner } from '@/shared/hooks'

const DEFAULT_COUNT = 8

// 트랙별로 답변 메모를 따로 보관한다. 프론트 로컬 전용이라 서버 계약과 무관.
const storageKeyFor = (track: PracticeTrack) => `stackup:practice-answers:${track}`

export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT_COUNT) {
const bankQuery = useQuery({
queryKey: ['practice-bank', track],
Expand All @@ -15,9 +19,6 @@ export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT

// seed 가 바뀌면 같은 은행에서 질문을 다시 뽑는다(다시 풀기).
const [seed, setSeed] = useState(0)
const [index, setIndex] = useState(0)
const [revealed, setRevealed] = useState(false)
const [answers, setAnswers] = useState<Record<string, string>>({})

const questions = useMemo(() => {
if (!bankQuery.data) return []
Expand All @@ -26,28 +27,13 @@ export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bankQuery.data, count, seed])

const total = questions.length
const current = questions[index]
const isLast = index >= total - 1
const done = total > 0 && index >= total

const reveal = useCallback(() => setRevealed(true), [])

const next = useCallback(() => {
setRevealed(false)
setIndex((i) => i + 1)
}, [])

const setAnswer = useCallback((id: string, value: string) => {
setAnswers((prev) => ({ ...prev, [id]: value }))
}, [])
const questionIds = useMemo(() => questions.map((q) => q.id), [questions])
const runner = useQuestionRunner(questionIds, storageKeyFor(track))

const restart = useCallback(() => {
setIndex(0)
setRevealed(false)
setAnswers({})
runner.reset()
setSeed((s) => s + 1)
}, [])
}, [runner])

return {
bankTitle: bankQuery.data?.title,
Expand All @@ -56,16 +42,16 @@ export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT
error: bankQuery.error as Error | undefined,
refetch: bankQuery.refetch,
questions,
current,
index,
total,
isLast,
done,
revealed,
answers,
reveal,
next,
setAnswer,
current: questions[runner.index],
index: runner.index,
total: runner.total,
isLast: runner.isLast,
done: runner.done,
revealed: runner.revealed,
answers: runner.answers,
reveal: runner.reveal,
next: runner.next,
setAnswer: runner.setAnswer,
restart,
}
}
9 changes: 8 additions & 1 deletion frontend/src/shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,15 @@ shared/api/

## 6. shared/hooks (도메인 비종속만)

권장 후보:
현재 구현:
- `useEventStream(url, options)` — SSE 추상화 (재연결, 폴링 fallback)
- `useAnalysisProgress`, `useCopyToClipboard`
- `useQuestionRunner(questionIds, storageKey?)` — 질문을 한 개씩 넘기며 답을 적고 정답을 확인하는
드릴 상태 기계. **연습 면접과 오답노트가 함께 쓰므로 여기 있다** — features 끼리는 서로
import 할 수 없다(FSD §3). 질문 id 목록만 받고 질문을 어디서 얻는지는 모른다(도메인 비종속).
`storageKey` 를 주면 답변 메모를 localStorage 에 남긴다(새로고침 유실 방지).

권장 후보:
- `useDebounce(value, ms)`
- `useThrottle(callback, ms)`
- `useMediaQuery(query)`
Expand Down
Loading
Loading