Skip to content

Commit e8197b9

Browse files
i3monthsclaude
andcommitted
feat(history): 점수 추이를 4개 지표 멀티라인 + 지난번 대비 델타로 확장
성장 추적 강화. 기존 ScoreTrend 는 종합 점수 한 줄만 그렸는데, recent[] 가 이미 싣고 있던 기술·논리·전달력을 함께 멀티 라인으로 시각화하고, 범례에 지표별 최신 점수 + 지난번 대비 델타(▲/▼)를 보여준다. "어떤 역량이 늘고 있는지"가 드러난다. 데이터는 이미 /api/users/me/stats 로 오므로 백엔드 변경 없음. - ScoreTrend: 4개 지표(종합/기술/논리/전달력) polyline + 색상 범례(최신값·델타). - 라이브러리 없이 기존 직접 SVG 패턴 확장. 테스트 추가. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 851f2bf commit e8197b9

2 files changed

Lines changed: 127 additions & 40 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import { ScoreTrend } from './ScoreTrend'
4+
import type { UserStats } from '../api/historyApi'
5+
6+
// recent 는 최신순(첫 항목이 가장 최근). reverse 후 시간순으로 그려진다.
7+
const stats: UserStats = {
8+
totalSessionCount: 2,
9+
completedSessionCount: 2,
10+
averages: { overall: 75, technical: 73, logic: 81, communication: 75 },
11+
recent: [
12+
{
13+
sessionId: 2,
14+
overall: 80,
15+
technical: 75,
16+
logic: 82,
17+
communication: 78,
18+
endedAt: '2026-06-02T00:00:00Z',
19+
},
20+
{
21+
sessionId: 1,
22+
overall: 70,
23+
technical: 72,
24+
logic: 80,
25+
communication: 72,
26+
endedAt: '2026-06-01T00:00:00Z',
27+
},
28+
],
29+
}
30+
31+
describe('ScoreTrend', () => {
32+
it('4개 지표 라벨 + 최신 점수 + 지난번 대비 델타를 보여준다', () => {
33+
render(<ScoreTrend stats={stats} />)
34+
expect(screen.getByText('지표별 점수 추이 (최근 2회)')).toBeInTheDocument()
35+
;['종합', '기술', '논리', '전달력'].forEach((l) =>
36+
expect(screen.getByText(l)).toBeInTheDocument(),
37+
)
38+
// 종합 최신 80, 지난번(70) 대비 ▲10
39+
expect(screen.getByText('80')).toBeInTheDocument()
40+
expect(screen.getByText('▲10')).toBeInTheDocument()
41+
})
42+
43+
it('채점된 면접이 없으면 안내 문구를 보여준다', () => {
44+
render(<ScoreTrend stats={{ recent: [] } as UserStats} />)
45+
expect(screen.getByText('아직 채점된 면접이 없어요.')).toBeInTheDocument()
46+
})
47+
})
Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
import type { UserStats } from '../api/historyApi'
22

33
const W = 320
4-
const H = 140
5-
const PAD = { l: 26, r: 10, t: 18, b: 10 }
4+
const H = 150
5+
const PAD = { l: 26, r: 10, t: 14, b: 10 }
66
const IW = W - PAD.l - PAD.r
77
const IH = H - PAD.t - PAD.b
88
const GRID = [0, 50, 100]
99

1010
const clamp = (v: number) => Math.max(0, Math.min(100, v))
1111

12-
// 종합 점수 추이를 라이브러리 없이 SVG 추세선(라인+영역)으로. recent 는 최신순이라 뒤집어 시간순으로.
12+
type MetricKey = 'overall' | 'technical' | 'logic' | 'communication'
13+
const METRICS: { key: MetricKey; label: string; color: string }[] = [
14+
{ key: 'overall', label: '종합', color: 'var(--color-primary)' },
15+
{ key: 'technical', label: '기술', color: 'var(--color-info)' },
16+
{ key: 'logic', label: '논리', color: 'var(--color-success)' },
17+
{ key: 'communication', label: '전달력', color: 'var(--color-warning)' },
18+
]
19+
20+
// 지표별(종합·기술·논리·전달력) 점수 추이를 라이브러리 없이 SVG 멀티 라인으로.
21+
// recent 는 최신순이라 뒤집어 시간순으로, 종합이 채점된 세션을 x축 스파인으로 쓴다.
1322
export function ScoreTrend({ stats }: { stats: UserStats }) {
14-
const points = [...(stats.recent ?? [])]
23+
const sessions = [...(stats.recent ?? [])]
1524
.reverse()
1625
.filter((r) => typeof r.overall === 'number')
17-
.map((r) => ({ sessionId: r.sessionId, score: clamp(r.overall as number) }))
26+
const n = sessions.length
1827

19-
if (points.length === 0) {
28+
if (n === 0) {
2029
return (
2130
<section className="flex flex-col gap-2 rounded-2xl border border-border bg-surface-raised p-5 shadow-sm">
2231
<span className="text-caption text-fg-muted">점수 추이</span>
@@ -25,22 +34,37 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
2534
)
2635
}
2736

28-
const n = points.length
2937
const sx = (i: number) => (n <= 1 ? PAD.l + IW / 2 : PAD.l + (IW * i) / (n - 1))
3038
const sy = (s: number) => PAD.t + IH * (1 - s / 100)
31-
const data = points.map((p, i) => ({ ...p, x: sx(i), y: sy(p.score) }))
32-
const linePts = data.map((d) => `${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' ')
33-
const areaPts = `${data[0].x.toFixed(1)},${PAD.t + IH} ${linePts} ${data[n - 1].x.toFixed(1)},${PAD.t + IH}`
39+
40+
const series = METRICS.map((m) => {
41+
const pts = sessions
42+
.map((s, i) =>
43+
typeof s[m.key] === 'number'
44+
? { x: sx(i), y: sy(clamp(s[m.key] as number)) }
45+
: null,
46+
)
47+
.filter((p): p is { x: number; y: number } => p !== null)
48+
const vals = sessions
49+
.map((s) => s[m.key])
50+
.filter((v): v is number => typeof v === 'number')
51+
const latest = vals.length ? Math.round(vals[vals.length - 1]) : null
52+
const delta =
53+
vals.length >= 2 ? Math.round(vals[vals.length - 1] - vals[vals.length - 2]) : null
54+
return { ...m, pts, latest, delta }
55+
})
3456

3557
return (
3658
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-surface-raised p-5 shadow-sm">
37-
<span className="text-caption text-fg-muted">종합 점수 추이 (최근 {n}회)</span>
59+
<span className="text-caption text-fg-muted">지표별 점수 추이 (최근 {n}회)</span>
3860
<svg
3961
viewBox={`0 0 ${W} ${H}`}
4062
className="h-36 w-full"
4163
preserveAspectRatio="none"
4264
role="img"
43-
aria-label={`종합 점수 추이, 최근 ${n}회: ${data.map((d) => `${d.score}점`).join(', ')}`}
65+
aria-label={`지표별 점수 추이, 최근 ${n}회. ${series
66+
.map((s) => `${s.label} ${s.latest ?? '미산정'}`)
67+
.join(', ')}`}
4468
>
4569
{/* y축 가이드라인 + 눈금(0/50/100) */}
4670
{GRID.map((g) => {
@@ -68,38 +92,54 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
6892
)
6993
})}
7094

71-
{/* 영역 + 추세선 (점 2개 이상일 때) */}
72-
{n >= 2 && (
73-
<>
74-
<polygon points={areaPts} style={{ fill: 'var(--color-primary)' }} fillOpacity={0.12} />
75-
<polyline
76-
points={linePts}
77-
fill="none"
78-
style={{ stroke: 'var(--color-primary)' }}
79-
strokeWidth={2}
80-
strokeLinejoin="round"
81-
strokeLinecap="round"
95+
{/* 지표별 추세선 (점 2개 이상일 때) */}
96+
{series.map(
97+
(s) =>
98+
s.pts.length >= 2 && (
99+
<polyline
100+
key={s.key}
101+
points={s.pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')}
102+
fill="none"
103+
style={{ stroke: s.color }}
104+
strokeWidth={1.75}
105+
strokeLinejoin="round"
106+
strokeLinecap="round"
107+
/>
108+
),
109+
)}
110+
{/* 데이터 포인트 */}
111+
{series.map((s) =>
112+
s.pts.map((p, i) => (
113+
<circle
114+
key={`${s.key}-${i}`}
115+
cx={p.x}
116+
cy={p.y}
117+
r={2}
118+
style={{ fill: s.color }}
82119
/>
83-
</>
120+
)),
84121
)}
122+
</svg>
85123

86-
{/* 데이터 포인트 + 값 라벨 */}
87-
{data.map((d) => (
88-
<g key={d.sessionId}>
89-
<circle cx={d.x} cy={d.y} r={3} style={{ fill: 'var(--color-primary)' }} />
90-
<text
91-
x={d.x}
92-
y={d.y - 7}
93-
textAnchor="middle"
94-
style={{ fill: 'var(--color-fg)' }}
95-
fontSize={10}
96-
fontWeight={600}
97-
>
98-
{d.score}
99-
</text>
100-
</g>
124+
{/* 범례 — 지표별 최신 점수 + 지난번 대비 델타 */}
125+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
126+
{series.map((s) => (
127+
<div key={s.key} className="flex items-center gap-1.5 text-caption">
128+
<span
129+
aria-hidden
130+
className="inline-block h-2 w-2 rounded-full"
131+
style={{ backgroundColor: s.color }}
132+
/>
133+
<span className="text-fg-muted">{s.label}</span>
134+
<span className="font-medium text-fg">{s.latest ?? '—'}</span>
135+
{s.delta != null && s.delta !== 0 && (
136+
<span className={s.delta > 0 ? 'text-success-700' : 'text-danger-700'}>
137+
{s.delta > 0 ? `▲${s.delta}` : `▼${Math.abs(s.delta)}`}
138+
</span>
139+
)}
140+
</div>
101141
))}
102-
</svg>
142+
</div>
103143
</section>
104144
)
105145
}

0 commit comments

Comments
 (0)