Skip to content

Commit 9850e10

Browse files
authored
Merge pull request #85 from Team-StackUp/feature/sprint3-homepage-improvement
Feature/sprint3 homepage improvement
2 parents b532d7d + 4798476 commit 9850e10

15 files changed

Lines changed: 552 additions & 36 deletions

File tree

frontend/src/app/router/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import AuthCallbackPage from '@/pages/AuthCallback'
66
import WorkspacePage from '@/pages/Workspace'
77
import InterviewSetupPage from '@/pages/InterviewSetup'
88
import InterviewSessionPage from '@/pages/InterviewSession'
9+
import PracticePage from '@/pages/Practice'
910
import SessionFeedbackPage from '@/pages/SessionFeedback'
1011
import SharedFeedbackPage from '@/pages/SharedFeedback'
1112

1213
export const router = createBrowserRouter([
1314
{ path: '/', element: <HomePage /> },
1415
{ path: '/login', element: <LoginPage /> },
16+
{ path: '/practice/:track', element: <PracticePage /> },
1517
{ path: '/share/:token', element: <SharedFeedbackPage /> },
1618
{ path: '/auth/callback', element: <AuthCallbackPage /> },
1719
{
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type {
2+
PracticeTrack,
3+
QuestionBank,
4+
PracticeQuestion,
5+
RawSubject,
6+
RawCategory,
7+
RawQuestion,
8+
} from './model/types'
9+
export { selectQuestions } from './lib/selectQuestions'
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from 'vitest'
2+
import type { QuestionBank } from '../model/types'
3+
import { selectQuestions } from './selectQuestions'
4+
5+
const q = (id: string) => ({ id, question: `Q-${id}`, answer: `A-${id}` })
6+
7+
function bank(spec: Record<string, Record<string, number>>): QuestionBank {
8+
return {
9+
version: '1.0',
10+
title: 'test',
11+
description: 'test',
12+
subjects: Object.entries(spec).map(([sid, cats]) => ({
13+
id: sid,
14+
name: sid,
15+
categories: Object.entries(cats).map(([cid, n]) => ({
16+
id: cid,
17+
name: cid,
18+
questions: Array.from({ length: n }, (_, i) => q(`${cid}-${i}`)),
19+
})),
20+
})),
21+
}
22+
}
23+
24+
// 결정적 RNG: 시퀀스를 순환하며 반환.
25+
function seq(values: number[]): () => number {
26+
let i = 0
27+
return () => values[i++ % values.length]
28+
}
29+
30+
describe('selectQuestions', () => {
31+
it('요청 개수만큼 중복 없이 반환한다', () => {
32+
const b = bank({ s1: { c1: 5, c2: 5 }, s2: { c3: 5 } })
33+
const picked = selectQuestions(b, 6, seq([0.1, 0.5, 0.9, 0.3, 0.7]))
34+
expect(picked).toHaveLength(6)
35+
expect(new Set(picked.map((p) => p.id)).size).toBe(6)
36+
})
37+
38+
it('풀보다 많이 요청하면 풀 크기로 제한된다', () => {
39+
const b = bank({ s1: { c1: 2 } })
40+
expect(selectQuestions(b, 10)).toHaveLength(2)
41+
})
42+
43+
it('과목/카테고리 메타데이터를 보존한다', () => {
44+
const b = bank({ Frontend: { html: 1 } })
45+
const [only] = selectQuestions(b, 1)
46+
expect(only).toMatchObject({ subject: 'Frontend', category: 'html', id: 'html-0' })
47+
})
48+
49+
it('rng=0 이면 가중치가 가장 큰 앞쪽 과목의 첫 질문이 먼저 나온다', () => {
50+
const b = bank({ s1: { c1: 3 }, s2: { c2: 3 }, s3: { c3: 3 } })
51+
// 누적 가중치 스캔에서 r<=0 즉시 첫 항목 선택 → 첫 과목 c1 의 첫 질문.
52+
const [first] = selectQuestions(b, 1, () => 0)
53+
expect(first.subject).toBe('s1')
54+
})
55+
56+
it('빈 은행에서는 빈 배열을 반환한다', () => {
57+
expect(selectQuestions(bank({}), 5)).toEqual([])
58+
})
59+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { PracticeQuestion, QuestionBank } from '../model/types'
2+
3+
// 각 질문 JSON 은 과목(subject)·카테고리·질문 순서가 모두 "빈출 → 마이너" 로
4+
// 정렬되어 있다. 따라서 앞쪽 과목/질문일수록 더 자주 출제되도록 가중치를 준다.
5+
//
6+
// - subjectWeight: 앞선 과목일수록 큰 선형 가중치 (N, N-1, … , 1).
7+
// - positionWeight: 카테고리 내 뒤쪽 질문일수록 완만하게 감소.
8+
// - categoryPenalty: 한 카테고리에서 뽑을 때마다 같은 카테고리의 남은 가중치를
9+
// 줄여, 특정 영역에 쏠리지 않고 카테고리가 고르게 분배되도록 한다.
10+
const POSITION_DECAY = 0.15
11+
const CATEGORY_PENALTY = 0.25
12+
13+
interface PoolEntry {
14+
question: PracticeQuestion
15+
categoryId: string
16+
weight: number
17+
}
18+
19+
function buildPool(bank: QuestionBank): PoolEntry[] {
20+
const pool: PoolEntry[] = []
21+
const subjectCount = bank.subjects.length
22+
23+
bank.subjects.forEach((subject, si) => {
24+
const subjectWeight = subjectCount - si
25+
subject.categories.forEach((category) => {
26+
category.questions.forEach((q, qi) => {
27+
pool.push({
28+
question: {
29+
id: q.id,
30+
question: q.question,
31+
answer: q.answer,
32+
subject: subject.name,
33+
category: category.name,
34+
},
35+
categoryId: category.id,
36+
weight: subjectWeight * (1 / (1 + qi * POSITION_DECAY)),
37+
})
38+
})
39+
})
40+
})
41+
42+
return pool
43+
}
44+
45+
function pickWeightedIndex(pool: PoolEntry[], rng: () => number): number {
46+
const total = pool.reduce((sum, e) => sum + e.weight, 0)
47+
if (total <= 0) return Math.floor(rng() * pool.length)
48+
49+
let r = rng() * total
50+
for (let i = 0; i < pool.length; i++) {
51+
r -= pool[i].weight
52+
if (r <= 0) return i
53+
}
54+
return pool.length - 1
55+
}
56+
57+
/**
58+
* 질문 은행에서 가중 무작위로 `count` 개의 질문을 (중복 없이) 선택한다.
59+
* 앞선 과목/질문에 가중치를 두되 카테고리가 한쪽으로 쏠리지 않게 분배한다.
60+
*
61+
* @param rng 0 이상 1 미만 난수 생성기 (테스트에서 주입 가능, 기본 Math.random)
62+
*/
63+
export function selectQuestions(
64+
bank: QuestionBank,
65+
count: number,
66+
rng: () => number = Math.random,
67+
): PracticeQuestion[] {
68+
const pool = buildPool(bank)
69+
const target = Math.min(count, pool.length)
70+
const picked: PracticeQuestion[] = []
71+
72+
for (let k = 0; k < target; k++) {
73+
const idx = pickWeightedIndex(pool, rng)
74+
const [chosen] = pool.splice(idx, 1)
75+
picked.push(chosen.question)
76+
for (const entry of pool) {
77+
if (entry.categoryId === chosen.categoryId) entry.weight *= CATEGORY_PENALTY
78+
}
79+
}
80+
81+
return picked
82+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// 정적(서버리스) 연습 면접 도메인 모델.
2+
// public/data/*.json 의 스키마(subjects → categories → questions)를 그대로 반영한다.
3+
4+
export type PracticeTrack = 'frontend' | 'backend' | 'cs'
5+
6+
export interface RawQuestion {
7+
id: string
8+
question: string
9+
answer: string
10+
}
11+
12+
export interface RawCategory {
13+
id: string
14+
name: string
15+
questions: RawQuestion[]
16+
}
17+
18+
export interface RawSubject {
19+
id: string
20+
name: string
21+
categories: RawCategory[]
22+
}
23+
24+
export interface QuestionBank {
25+
version: string
26+
title: string
27+
description: string
28+
subjects: RawSubject[]
29+
}
30+
31+
// 면접에서 출제되는 단일 질문. 어느 과목/카테고리에서 왔는지 함께 보존한다.
32+
export interface PracticeQuestion {
33+
id: string
34+
question: string
35+
answer: string
36+
subject: string
37+
category: string
38+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { PracticeTrack, QuestionBank } from '@/domain/practice'
2+
3+
// 서버 비용을 피하기 위해 질문 은행은 public/data 의 정적 JSON 으로 제공된다.
4+
const TRACK_FILE: Record<PracticeTrack, string> = {
5+
frontend: '/data/frontend-interview-questions.json',
6+
backend: '/data/backend-interview-questions.json',
7+
cs: '/data/cs_interview_questions.json',
8+
}
9+
10+
export async function loadQuestionBank(track: PracticeTrack): Promise<QuestionBank> {
11+
const res = await fetch(TRACK_FILE[track])
12+
if (!res.ok) {
13+
throw new Error(`질문 데이터를 불러오지 못했습니다 (${res.status})`)
14+
}
15+
return (await res.json()) as QuestionBank
16+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { PracticeRunner } from './ui/PracticeRunner'
2+
export { TrackPicker } from './ui/TrackPicker'
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useCallback, useMemo, useState } from 'react'
2+
import { useQuery } from '@tanstack/react-query'
3+
import { selectQuestions } from '@/domain/practice'
4+
import type { PracticeTrack } from '@/domain/practice'
5+
import { loadQuestionBank } from '../api/loadQuestionBank'
6+
7+
const DEFAULT_COUNT = 8
8+
9+
export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT_COUNT) {
10+
const bankQuery = useQuery({
11+
queryKey: ['practice-bank', track],
12+
queryFn: () => loadQuestionBank(track),
13+
staleTime: Infinity,
14+
})
15+
16+
// seed 가 바뀌면 같은 은행에서 질문을 다시 뽑는다(다시 풀기).
17+
const [seed, setSeed] = useState(0)
18+
const [index, setIndex] = useState(0)
19+
const [revealed, setRevealed] = useState(false)
20+
const [answers, setAnswers] = useState<Record<string, string>>({})
21+
22+
const questions = useMemo(() => {
23+
if (!bankQuery.data) return []
24+
return selectQuestions(bankQuery.data, count)
25+
// seed 를 의존성에 포함해 "다시 풀기" 시 새 표본을 뽑는다.
26+
// eslint-disable-next-line react-hooks/exhaustive-deps
27+
}, [bankQuery.data, count, seed])
28+
29+
const total = questions.length
30+
const current = questions[index]
31+
const isLast = index >= total - 1
32+
const done = total > 0 && index >= total
33+
34+
const reveal = useCallback(() => setRevealed(true), [])
35+
36+
const next = useCallback(() => {
37+
setRevealed(false)
38+
setIndex((i) => i + 1)
39+
}, [])
40+
41+
const setAnswer = useCallback((id: string, value: string) => {
42+
setAnswers((prev) => ({ ...prev, [id]: value }))
43+
}, [])
44+
45+
const restart = useCallback(() => {
46+
setIndex(0)
47+
setRevealed(false)
48+
setAnswers({})
49+
setSeed((s) => s + 1)
50+
}, [])
51+
52+
return {
53+
bankTitle: bankQuery.data?.title,
54+
isLoading: bankQuery.isLoading,
55+
isError: bankQuery.isError,
56+
error: bankQuery.error as Error | undefined,
57+
refetch: bankQuery.refetch,
58+
questions,
59+
current,
60+
index,
61+
total,
62+
isLast,
63+
done,
64+
revealed,
65+
answers,
66+
reveal,
67+
next,
68+
setAnswer,
69+
restart,
70+
}
71+
}

0 commit comments

Comments
 (0)