From e8197b9812eb515fdd98f075698c4ffc16b2dd88 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 25 Jun 2026 15:29:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(history):=20=EC=A0=90=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=204=EA=B0=9C=20=EC=A7=80=ED=91=9C=20?= =?UTF-8?q?=EB=A9=80=ED=8B=B0=EB=9D=BC=EC=9D=B8=20+=20=EC=A7=80=EB=82=9C?= =?UTF-8?q?=EB=B2=88=20=EB=8C=80=EB=B9=84=20=EB=8D=B8=ED=83=80=EB=A1=9C=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 성장 추적 강화. 기존 ScoreTrend 는 종합 점수 한 줄만 그렸는데, recent[] 가 이미 싣고 있던 기술·논리·전달력을 함께 멀티 라인으로 시각화하고, 범례에 지표별 최신 점수 + 지난번 대비 델타(▲/▼)를 보여준다. "어떤 역량이 늘고 있는지"가 드러난다. 데이터는 이미 /api/users/me/stats 로 오므로 백엔드 변경 없음. - ScoreTrend: 4개 지표(종합/기술/논리/전달력) polyline + 색상 범례(최신값·델타). - 라이브러리 없이 기존 직접 SVG 패턴 확장. 테스트 추가. Co-Authored-By: Claude Opus 4.8 --- .../features/history/ui/ScoreTrend.test.tsx | 47 +++++++ .../src/features/history/ui/ScoreTrend.tsx | 120 ++++++++++++------ 2 files changed, 127 insertions(+), 40 deletions(-) create mode 100644 frontend/src/features/history/ui/ScoreTrend.test.tsx diff --git a/frontend/src/features/history/ui/ScoreTrend.test.tsx b/frontend/src/features/history/ui/ScoreTrend.test.tsx new file mode 100644 index 00000000..87592964 --- /dev/null +++ b/frontend/src/features/history/ui/ScoreTrend.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ScoreTrend } from './ScoreTrend' +import type { UserStats } from '../api/historyApi' + +// recent 는 최신순(첫 항목이 가장 최근). reverse 후 시간순으로 그려진다. +const stats: UserStats = { + totalSessionCount: 2, + completedSessionCount: 2, + averages: { overall: 75, technical: 73, logic: 81, communication: 75 }, + recent: [ + { + sessionId: 2, + overall: 80, + technical: 75, + logic: 82, + communication: 78, + endedAt: '2026-06-02T00:00:00Z', + }, + { + sessionId: 1, + overall: 70, + technical: 72, + logic: 80, + communication: 72, + endedAt: '2026-06-01T00:00:00Z', + }, + ], +} + +describe('ScoreTrend', () => { + it('4개 지표 라벨 + 최신 점수 + 지난번 대비 델타를 보여준다', () => { + render() + expect(screen.getByText('지표별 점수 추이 (최근 2회)')).toBeInTheDocument() + ;['종합', '기술', '논리', '전달력'].forEach((l) => + expect(screen.getByText(l)).toBeInTheDocument(), + ) + // 종합 최신 80, 지난번(70) 대비 ▲10 + expect(screen.getByText('80')).toBeInTheDocument() + expect(screen.getByText('▲10')).toBeInTheDocument() + }) + + it('채점된 면접이 없으면 안내 문구를 보여준다', () => { + render() + expect(screen.getByText('아직 채점된 면접이 없어요.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/history/ui/ScoreTrend.tsx b/frontend/src/features/history/ui/ScoreTrend.tsx index 5c5c63a2..459c9620 100644 --- a/frontend/src/features/history/ui/ScoreTrend.tsx +++ b/frontend/src/features/history/ui/ScoreTrend.tsx @@ -1,22 +1,31 @@ import type { UserStats } from '../api/historyApi' const W = 320 -const H = 140 -const PAD = { l: 26, r: 10, t: 18, b: 10 } +const H = 150 +const PAD = { l: 26, r: 10, t: 14, 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 는 최신순이라 뒤집어 시간순으로. +type MetricKey = 'overall' | 'technical' | 'logic' | 'communication' +const METRICS: { key: MetricKey; label: string; color: string }[] = [ + { key: 'overall', label: '종합', color: 'var(--color-primary)' }, + { key: 'technical', label: '기술', color: 'var(--color-info)' }, + { key: 'logic', label: '논리', color: 'var(--color-success)' }, + { key: 'communication', label: '전달력', color: 'var(--color-warning)' }, +] + +// 지표별(종합·기술·논리·전달력) 점수 추이를 라이브러리 없이 SVG 멀티 라인으로. +// recent 는 최신순이라 뒤집어 시간순으로, 종합이 채점된 세션을 x축 스파인으로 쓴다. export function ScoreTrend({ stats }: { stats: UserStats }) { - const points = [...(stats.recent ?? [])] + const sessions = [...(stats.recent ?? [])] .reverse() .filter((r) => typeof r.overall === 'number') - .map((r) => ({ sessionId: r.sessionId, score: clamp(r.overall as number) })) + const n = sessions.length - if (points.length === 0) { + if (n === 0) { return (
점수 추이 @@ -25,22 +34,37 @@ 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}` + + const series = METRICS.map((m) => { + const pts = sessions + .map((s, i) => + typeof s[m.key] === 'number' + ? { x: sx(i), y: sy(clamp(s[m.key] as number)) } + : null, + ) + .filter((p): p is { x: number; y: number } => p !== null) + const vals = sessions + .map((s) => s[m.key]) + .filter((v): v is number => typeof v === 'number') + const latest = vals.length ? Math.round(vals[vals.length - 1]) : null + const delta = + vals.length >= 2 ? Math.round(vals[vals.length - 1] - vals[vals.length - 2]) : null + return { ...m, pts, latest, delta } + }) return (
- 종합 점수 추이 (최근 {n}회) + 지표별 점수 추이 (최근 {n}회) `${d.score}점`).join(', ')}`} + aria-label={`지표별 점수 추이, 최근 ${n}회. ${series + .map((s) => `${s.label} ${s.latest ?? '미산정'}`) + .join(', ')}`} > {/* y축 가이드라인 + 눈금(0/50/100) */} {GRID.map((g) => { @@ -68,38 +92,54 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { ) })} - {/* 영역 + 추세선 (점 2개 이상일 때) */} - {n >= 2 && ( - <> - - + s.pts.length >= 2 && ( + `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')} + fill="none" + style={{ stroke: s.color }} + strokeWidth={1.75} + strokeLinejoin="round" + strokeLinecap="round" + /> + ), + )} + {/* 데이터 포인트 */} + {series.map((s) => + s.pts.map((p, i) => ( + - + )), )} + - {/* 데이터 포인트 + 값 라벨 */} - {data.map((d) => ( - - - - {d.score} - - + {/* 범례 — 지표별 최신 점수 + 지난번 대비 델타 */} +
+ {series.map((s) => ( +
+ + {s.label} + {s.latest ?? '—'} + {s.delta != null && s.delta !== 0 && ( + 0 ? 'text-success-700' : 'text-danger-700'}> + {s.delta > 0 ? `▲${s.delta}` : `▼${Math.abs(s.delta)}`} + + )} +
))} - +
) }