diff --git a/frontend/src/features/history/ui/ScoreTrend.tsx b/frontend/src/features/history/ui/ScoreTrend.tsx index b39d328f..5c5c63a2 100644 --- a/frontend/src/features/history/ui/ScoreTrend.tsx +++ b/frontend/src/features/history/ui/ScoreTrend.tsx @@ -1,10 +1,20 @@ import type { UserStats } from '../api/historyApi' -// 최근 종합 점수 추이를 라이브러리 없이 inline 막대로. recent 는 최신순이라 뒤집어 시간순으로. +const W = 320 +const H = 140 +const PAD = { l: 26, r: 10, t: 18, b: 10 } +const IW = W - PAD.l - PAD.r +const IH = H - PAD.t - PAD.b +const GRID = [0, 50, 100] + +const clamp = (v: number) => Math.max(0, Math.min(100, v)) + +// 종합 점수 추이를 라이브러리 없이 SVG 추세선(라인+영역)으로. recent 는 최신순이라 뒤집어 시간순으로. export function ScoreTrend({ stats }: { stats: UserStats }) { const points = [...(stats.recent ?? [])] .reverse() .filter((r) => typeof r.overall === 'number') + .map((r) => ({ sessionId: r.sessionId, score: clamp(r.overall as number) })) if (points.length === 0) { return ( @@ -15,36 +25,81 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { ) } + const n = points.length + const sx = (i: number) => (n <= 1 ? PAD.l + IW / 2 : PAD.l + (IW * i) / (n - 1)) + const sy = (s: number) => PAD.t + IH * (1 - s / 100) + const data = points.map((p, i) => ({ ...p, x: sx(i), y: sy(p.score) })) + const linePts = data.map((d) => `${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' ') + const areaPts = `${data[0].x.toFixed(1)},${PAD.t + IH} ${linePts} ${data[n - 1].x.toFixed(1)},${PAD.t + IH}` + return (
- 종합 점수 추이 (최근 {points.length}회) -
종합 점수 추이 (최근 {n}회) + `${Math.round(Math.max(0, Math.min(100, r.overall as number)))}점`) - .join(', ')}`} + aria-label={`종합 점수 추이, 최근 ${n}회: ${data.map((d) => `${d.score}점`).join(', ')}`} > - {points.map((r) => { - const score = Math.max(0, Math.min(100, r.overall as number)) + {/* y축 가이드라인 + 눈금(0/50/100) */} + {GRID.map((g) => { + const y = sy(g) return ( -
-
-
-
- {Math.round(score)} -
+ + + + {g} + + ) })} -
+ + {/* 영역 + 추세선 (점 2개 이상일 때) */} + {n >= 2 && ( + <> + + + + )} + + {/* 데이터 포인트 + 값 라벨 */} + {data.map((d) => ( + + + + {d.score} + + + ))} +
) } diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 670a049a..90b8aa9b 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -132,6 +132,9 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = const action = interviewEventAction(frame.event) if (action.kind === 'refetch-messages') { void queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }) + // 헤더 질문 카운트(질문 N/M)는 session.totalQuestionCount 기반이라, + // 새 메시지(질문)마다 세션도 갱신해 진행도가 라이브로 반영되게 한다. + void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) }) } else if (action.kind === 'refetch-session') { void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) }) } else if (action.kind === 'redirect-feedback') {