From c83027e9ab5a471111e049dc88d923223085573d Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 15 Jun 2026 16:00:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(history,interview):=20=EC=A0=90=EC=88=98?= =?UTF-8?q?=20=EC=B6=94=EC=9D=B4=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=ED=99=94=20+=20=EC=A7=88=EB=AC=B8=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScoreTrend: 막대 → SVG 추세선 그래프(영역+라인+포인트, 0/50/100 가이드, 값 라벨). 라이브러리 없이 구현, 접근성 aria-label 유지 - 라이브 면접 헤더 질문 카운트(질문 N/M): SESSION_MESSAGE 시 메시지만 refetch 하던 것을 세션도 함께 갱신 → totalQuestionCount 기반 진행도가 질문 진행에 따라 실시간 반영 Co-Authored-By: Claude Opus 4.8 --- .../src/features/history/ui/ScoreTrend.tsx | 103 ++++++++++++++---- .../interview/model/useLiveInterview.ts | 3 + 2 files changed, 82 insertions(+), 24 deletions(-) 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') {